Skip to content

feat: configurable recording-indicator position (top vs near cursor)#242

Open
mvanhorn wants to merge 3 commits into
zachlatta:mainfrom
mvanhorn:feat/212-configurable-recording-indicator-positio
Open

feat: configurable recording-indicator position (top vs near cursor)#242
mvanhorn wants to merge 3 commits into
zachlatta:mainfrom
mvanhorn:feat/212-configurable-recording-indicator-positio

Conversation

@mvanhorn

@mvanhorn mvanhorn commented Jun 23, 2026

Copy link
Copy Markdown

What

Adds an optional setting to choose where the recording / voice-input indicator appears: keep the current top-of-screen drop-down (default), or show it near the cursor / text caret while dictating.

Closes #212.

Why

On slow or unstable connections the recording indicator only lives at the top of the screen, so the user has to keep glancing up to confirm dictation is still active. For people dictating into a text field lower on the display, that means looking away from where the text is actually landing. This adds a "Show at" choice so the indicator can follow the active input instead.

How

Follows the same pattern already used for the multi-display "Show on" picker.

  • Sources/SettingsView.swift — new @AppStorage("overlay_vertical_position") key (0 = top, default; 1 = near cursor) and a small "Show at" Picker added to the overlay settings group, mirroring the existing overlayDisplaySection layout and accessibility wiring.
  • Sources/RecordingOverlay.swift — the drop-down pill's overlayFrame branches on the new key. Position 0 keeps the existing screen.frame.maxY-anchored placement byte-for-byte (including notch handling), so existing users see no change. Position 1 anchors the pill just below the active text caret, resolved via the Accessibility API (focused element → selected-text-range bounds), and falls back to NSEvent.mouseLocation when AX bounds are unavailable. The result is clamped fully inside the target screen's visibleFrame so it never clips at an edge, the entrance animation slides locally instead of dropping from the menu bar, and the pill rounds all four corners since it is no longer flush with the top. The minimalist notch / winged layout stays top-anchored.

Behavior

  • Default (top): unchanged, including on notched displays.
  • Near cursor: the pill appears just below the focused caret (or the pointer as a fallback), staying within the active screen's visible frame.
  • Multi-monitor: near-cursor placement targets the screen the caret / cursor is on and composes with the existing overlay_display_id setting.
  • Persistence: the choice is stored in UserDefaults (@AppStorage) and survives relaunch.
  • Edge clamp: near a bottom or right edge, the panel is kept fully on-screen rather than rendering partially off-display.

Testing

Built locally with make CODESIGN_IDENTITY=- (ad-hoc signed) against the macOS SDK — clean compile of all sources including the two changed files.

Summary by CodeRabbit

  • New Features
    • Added configurable recording overlay placement via new "Show at" setting in preferences
    • Users can now choose between top-of-screen or near-cursor positioning for the recording overlay during dictation
    • Near-cursor placement smoothly animates the overlay from the cursor position

@coderabbitai

coderabbitai Bot commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a "near cursor" placement mode for the recording overlay. A new overlay_vertical_position preference is persisted via AppStorage and surfaced in Settings as a "Show at" picker. RecordingOverlay.swift gains AX-based anchor-point helpers, a usesNearCursorOverlayPlacement flag, and updated frame/animation logic to position and clamp the overlay near the focused element's selected text.

Changes

Near-cursor recording overlay placement

Layer / File(s) Summary
Settings persistence and UI picker
Sources/SettingsView.swift
Adds @AppStorage("overlay_vertical_position") and a new "Show at" picker section to the Recording Overlay settings card with top-of-screen (0) and near-cursor (1) options.
Near-cursor placement state and constants
Sources/RecordingOverlay.swift
Imports ApplicationServices, adds nearCursorOverlayGap static constant, and introduces usesNearCursorOverlayPlacement computed from overlay_vertical_position (disabled when useWingedLayout is active).
AX anchor-point and geometry helpers
Sources/RecordingOverlay.swift
Adds screen(containing:fallback:), nearCursorAnchorPoint() (resolves focused element's selected-text bounds via AXUIElement attributes, falls back to mouse position), and clampedFrame(_:to:) for constraining frames to a visible rectangle.
Frame calculation and show-panel animation
Sources/RecordingOverlay.swift
Updates showOverlayPanel entrance frame to offset upward and clamp to screen when near-cursor mode is active; updates overlayFrame to derive anchor via nearCursorAnchorPoint(), use fixed 38pt height, center on anchor, and return a clamped rect; adjusts makeOverlayContent/makeNotchContent corner radius, top padding, and roundsTopCorners based on usesNearCursorOverlayPlacement.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant SettingsView
    participant UserDefaults
    participant RecordingOverlayManager
    participant AXUIElement
    participant NSScreen

    User->>SettingsView: selects "Near Cursor" in Show at picker
    SettingsView->>UserDefaults: overlay_vertical_position = 1

    User->>RecordingOverlayManager: starts dictation
    RecordingOverlayManager->>RecordingOverlayManager: usesNearCursorOverlayPlacement = true
    RecordingOverlayManager->>RecordingOverlayManager: nearCursorAnchorPoint()
    RecordingOverlayManager->>AXUIElement: AXFocusedUIElement → AXSelectedTextBounds
    alt AX bounds available
        AXUIElement-->>RecordingOverlayManager: selection rect → anchor point
    else fallback
        RecordingOverlayManager->>RecordingOverlayManager: NSEvent.mouseLocation
    end
    RecordingOverlayManager->>NSScreen: screen(containing: anchorPoint)
    RecordingOverlayManager->>RecordingOverlayManager: clampedFrame to visibleFrame
    RecordingOverlayManager->>User: overlay appears near cursor/selection
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • zachlatta/freeflow#192: Modifies showOverlayPanel and overlayFrame in RecordingOverlay.swift to select a target screen for notch geometry — the same code paths this PR extends for near-cursor placement.
  • zachlatta/freeflow#241: Touches overlayFrame sizing/layout for feedback error messages, directly overlapping with the overlayFrame conditional height and positioning logic changed in this PR.

Suggested labels

size/m

🐇 Hop, hop, the overlay jumps near,
No longer stuck at the top, my dear!
With AX magic and a clamped frame in tow,
It follows your cursor wherever you go.
A tiny pill of UI, floating just right —
Dictate away, no more upward sight! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main feature addition: making the recording-indicator position configurable between top and near-cursor options.
Linked Issues check ✅ Passed The PR fully implements the configurable recording indicator position requested in #212, allowing users to choose between top-of-screen and near-cursor placement.
Out of Scope Changes check ✅ Passed All changes are directly scoped to the configurable indicator positioning feature: settings UI, overlay positioning logic, and supporting helper methods.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
Sources/RecordingOverlay.swift (1)

464-464: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Prefer usesNearCursorOverlayPlacement for consistency.

Elsewhere (showOverlayPanel L326, makeOverlayContent L386) the placement decision uses usesNearCursorOverlayPlacement, but here it's the raw overlayVerticalPosition == 1. They're equivalent only because the useWingedLayout early-return at L449 already excluded the winged case; using the same computed property avoids a future refactor reintroducing a winged near-cursor frame by accident.

♻️ Use the shared flag
-        let usesNearCursorPosition = overlayVerticalPosition == 1
+        let usesNearCursorPosition = usesNearCursorOverlayPlacement
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/RecordingOverlay.swift` at line 464, Replace the raw comparison
`overlayVerticalPosition == 1` with the existing computed property
`usesNearCursorOverlayPlacement` on line 464 to maintain consistency with how
the same placement decision is made in other methods like `showOverlayPanel` and
`makeOverlayContent`. This ensures the same logic is used throughout and reduces
the risk of reintroducing bugs in future refactoring.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Sources/RecordingOverlay.swift`:
- Around line 325-337: The entrance frame is being clamped to overlayScreen
derived from the mouse pointer location, but the final frame is positioned
relative to the anchor point which may be on a different screen. When the caret
and mouse pointer are on different displays, this causes incorrect clamping. Fix
this by ensuring overlayScreen is computed using the same screen logic as the
final frame, rather than using NSEvent.mouseLocation. Recompute the anchor point
or derive the screen from the anchor's location instead of the mouse location in
the screen(containing:fallback:) call when usesNearCursorOverlayPlacement is
true.
- Around line 157-172: The nearCursorAnchorPoint() function makes synchronous
Accessibility Framework calls on the main thread that can block indefinitely if
the focused application is unresponsive. To prevent UI freezing, use
AXUIElementSetMessagingTimeout to establish timeout limits on the AX messaging
calls. Set the messaging timeout on both the systemWide element immediately
after creating it and also on the focusedElement after retrieving it, ensuring
that the timeout applies throughout the entire call chain and prevents the main
thread from being blocked by unresponsive target applications.

---

Nitpick comments:
In `@Sources/RecordingOverlay.swift`:
- Line 464: Replace the raw comparison `overlayVerticalPosition == 1` with the
existing computed property `usesNearCursorOverlayPlacement` on line 464 to
maintain consistency with how the same placement decision is made in other
methods like `showOverlayPanel` and `makeOverlayContent`. This ensures the same
logic is used throughout and reduces the risk of reintroducing bugs in future
refactoring.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f84aca0d-1313-4710-88ea-51d64e232318

📥 Commits

Reviewing files that changed from the base of the PR and between 13e2788 and 8d6131b.

📒 Files selected for processing (2)
  • Sources/RecordingOverlay.swift
  • Sources/SettingsView.swift

Comment on lines +157 to +172
private func nearCursorAnchorPoint() -> NSPoint {
let mouseLocation = NSEvent.mouseLocation
let systemWide = AXUIElementCreateSystemWide()
var focusedValue: CFTypeRef?, rangeValue: CFTypeRef?, boundsValue: CFTypeRef?
guard AXUIElementCopyAttributeValue(systemWide, kAXFocusedUIElementAttribute as CFString, &focusedValue) == .success,
let focusedRaw = focusedValue, CFGetTypeID(focusedRaw) == AXUIElementGetTypeID() else { return mouseLocation }
let focusedElement = unsafeBitCast(focusedRaw, to: AXUIElement.self)
guard AXUIElementCopyAttributeValue(focusedElement, kAXSelectedTextRangeAttribute as CFString, &rangeValue) == .success,
let rangeRaw = rangeValue, CFGetTypeID(rangeRaw) == AXValueGetTypeID(),
AXUIElementCopyParameterizedAttributeValue(focusedElement, kAXBoundsForRangeParameterizedAttribute as CFString, rangeRaw, &boundsValue) == .success,
let boundsRaw = boundsValue, CFGetTypeID(boundsRaw) == AXValueGetTypeID() else { return mouseLocation }
var rect = CGRect.zero
guard AXValueGetValue(unsafeBitCast(boundsRaw, to: AXValue.self), .cgRect, &rect), rect.height > 0, rect != .zero else { return mouseLocation }
let mainHeight = NSScreen.screens.first?.frame.height ?? mouseLocation.y + rect.maxY
return NSPoint(x: rect.midX, y: mainHeight - rect.maxY)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Does AXUIElementSetMessagingTimeout on the system-wide AXUIElement apply to child elements, and can AXUIElementCopyAttributeValue block the calling thread without a timeout?

💡 Result:

The function AXUIElementSetMessagingTimeout does not automatically apply to child elements when called on the system-wide AXUIElement [1][2]. According to official technical documentation, setting the timeout on an accessibility object (including the system-wide one) applies specifically to that object instance [1][2]. When you call AXUIElementSetMessagingTimeout on the system-wide accessibility object, it sets a global messaging timeout for the current process, which acts as a default for other accessibility objects that do not have their own specific timeout set [1][2]. If an individual accessibility element has had a specific timeout set on it, that element will use its own timeout rather than the global one [1][2]. Regarding AXUIElementCopyAttributeValue, this function can indeed block the calling thread [3]. The accessibility API performs inter-process communication (IPC) to retrieve information from other applications [4]. If the target application is unresponsive, busy, or otherwise unable to respond to the accessibility request in a timely manner, the calling thread will block while waiting for the operation to complete or time out [3]. Because these calls are synchronous, it is common practice to perform accessibility queries on a background thread to avoid hanging the main application thread [3]. Setting a messaging timeout is one way to define how long the API will wait before returning an error (such as kAXErrorCannotComplete) instead of blocking indefinitely [1][2][5].

Citations:


🏁 Script executed:

# First, examine the file structure and find the relevant functions
cd Sources
cat -n RecordingOverlay.swift | head -200

Repository: zachlatta/freeflow

Length of output: 9470


🏁 Script executed:

# Continue reading the file to find where nearCursorAnchorPoint is called
cd Sources
sed -n '174,400p' RecordingOverlay.swift

Repository: zachlatta/freeflow

Length of output: 8870


🏁 Script executed:

# Continue to find the overlayFrame property
cd Sources
sed -n '400,600p' RecordingOverlay.swift

Repository: zachlatta/freeflow

Length of output: 8152


Synchronous AX queries run on the main thread during overlay show — risk of UI hang.

nearCursorAnchorPoint() issues cross-process Accessibility calls (AXUIElementCopyAttributeValue, AXUIElementCopyParameterizedAttributeValue). It's reached via overlayFrame inside showOverlayPanel/updateOverlayLayout, which run on DispatchQueue.main. If the focused application is busy or unresponsive, these synchronous AX messages can block the main run loop until the system timeout elapses, freezing the app at the exact moment recording starts.

Bound the wait with a per-request timeout so a stalled target app can't freeze the overlay:

🔒 Bound AX messaging time
     private func nearCursorAnchorPoint() -> NSPoint {
         let mouseLocation = NSEvent.mouseLocation
         let systemWide = AXUIElementCreateSystemWide()
+        // Don't let an unresponsive focused app stall the main thread.
+        AXUIElementSetMessagingTimeout(systemWide, 0.25)
         var focusedValue: CFTypeRef?, rangeValue: CFTypeRef?, boundsValue: CFTypeRef?

Note that AXUIElementSetMessagingTimeout on the system-wide element sets a default timeout for the current process; individual elements like focusedElement will use their own timeout if one is set explicitly. Consider setting the timeout on focusedElement as well to ensure the messaging limit applies throughout the entire call chain.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private func nearCursorAnchorPoint() -> NSPoint {
let mouseLocation = NSEvent.mouseLocation
let systemWide = AXUIElementCreateSystemWide()
var focusedValue: CFTypeRef?, rangeValue: CFTypeRef?, boundsValue: CFTypeRef?
guard AXUIElementCopyAttributeValue(systemWide, kAXFocusedUIElementAttribute as CFString, &focusedValue) == .success,
let focusedRaw = focusedValue, CFGetTypeID(focusedRaw) == AXUIElementGetTypeID() else { return mouseLocation }
let focusedElement = unsafeBitCast(focusedRaw, to: AXUIElement.self)
guard AXUIElementCopyAttributeValue(focusedElement, kAXSelectedTextRangeAttribute as CFString, &rangeValue) == .success,
let rangeRaw = rangeValue, CFGetTypeID(rangeRaw) == AXValueGetTypeID(),
AXUIElementCopyParameterizedAttributeValue(focusedElement, kAXBoundsForRangeParameterizedAttribute as CFString, rangeRaw, &boundsValue) == .success,
let boundsRaw = boundsValue, CFGetTypeID(boundsRaw) == AXValueGetTypeID() else { return mouseLocation }
var rect = CGRect.zero
guard AXValueGetValue(unsafeBitCast(boundsRaw, to: AXValue.self), .cgRect, &rect), rect.height > 0, rect != .zero else { return mouseLocation }
let mainHeight = NSScreen.screens.first?.frame.height ?? mouseLocation.y + rect.maxY
return NSPoint(x: rect.midX, y: mainHeight - rect.maxY)
}
private func nearCursorAnchorPoint() -> NSPoint {
let mouseLocation = NSEvent.mouseLocation
let systemWide = AXUIElementCreateSystemWide()
// Don't let an unresponsive focused app stall the main thread.
AXUIElementSetMessagingTimeout(systemWide, 0.25)
var focusedValue: CFTypeRef?, rangeValue: CFTypeRef?, boundsValue: CFTypeRef?
guard AXUIElementCopyAttributeValue(systemWide, kAXFocusedUIElementAttribute as CFString, &focusedValue) == .success,
let focusedRaw = focusedValue, CFGetTypeID(focusedRaw) == AXUIElementGetTypeID() else { return mouseLocation }
let focusedElement = unsafeBitCast(focusedRaw, to: AXUIElement.self)
guard AXUIElementCopyAttributeValue(focusedElement, kAXSelectedTextRangeAttribute as CFString, &rangeValue) == .success,
let rangeRaw = rangeValue, CFGetTypeID(rangeRaw) == AXValueGetTypeID(),
AXUIElementCopyParameterizedAttributeValue(focusedElement, kAXBoundsForRangeParameterizedAttribute as CFString, rangeRaw, &boundsValue) == .success,
let boundsRaw = boundsValue, CFGetTypeID(boundsRaw) == AXValueGetTypeID() else { return mouseLocation }
var rect = CGRect.zero
guard AXValueGetValue(unsafeBitCast(boundsRaw, to: AXValue.self), .cgRect, &rect), rect.height > 0, rect != .zero else { return mouseLocation }
let mainHeight = NSScreen.screens.first?.frame.height ?? mouseLocation.y + rect.maxY
return NSPoint(x: rect.midX, y: mainHeight - rect.maxY)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/RecordingOverlay.swift` around lines 157 - 172, The
nearCursorAnchorPoint() function makes synchronous Accessibility Framework calls
on the main thread that can block indefinitely if the focused application is
unresponsive. To prevent UI freezing, use AXUIElementSetMessagingTimeout to
establish timeout limits on the AX messaging calls. Set the messaging timeout on
both the systemWide element immediately after creating it and also on the
focusedElement after retrieving it, ensuring that the timeout applies throughout
the entire call chain and prevents the main thread from being blocked by
unresponsive target applications.

Comment on lines +325 to +337
let hiddenFrame: NSRect
if usesNearCursorOverlayPlacement {
let overlayScreen = self.screen(containing: NSEvent.mouseLocation, fallback: screen)
let entranceFrame = NSRect(
x: frame.origin.x,
y: frame.origin.y + frame.height,
width: frame.width,
height: frame.height
)
hiddenFrame = clampedFrame(entranceFrame, to: overlayScreen.visibleFrame)
} else {
hiddenFrame = NSRect(x: frame.origin.x, y: screen.frame.maxY, width: frame.width, height: frame.height)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Entrance frame clamps to the pointer's screen, not the anchor's screen.

frame (from overlayFrame) is positioned relative to nearCursorAnchorPoint() — which prefers the focused caret over the pointer — and is clamped to the caret's screen. Here the entrance/hidden frame is instead clamped to screen(containing: NSEvent.mouseLocation, …). When the caret and the mouse pointer sit on different displays, the entrance frame is clamped to the wrong screen, producing a janky cross-monitor slide before settling at frame.

Recompute the anchor (or reuse it) so the entrance screen matches the final frame's screen.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/RecordingOverlay.swift` around lines 325 - 337, The entrance frame is
being clamped to overlayScreen derived from the mouse pointer location, but the
final frame is positioned relative to the anchor point which may be on a
different screen. When the caret and mouse pointer are on different displays,
this causes incorrect clamping. Fix this by ensuring overlayScreen is computed
using the same screen logic as the final frame, rather than using
NSEvent.mouseLocation. Recompute the anchor point or derive the screen from the
anchor's location instead of the mouse location in the
screen(containing:fallback:) call when usesNearCursorOverlayPlacement is true.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Configurable/floating recording indicator

1 participant