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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ This project uses semantic versioning for public releases. Use `MAJOR.MINOR.PATC
- `MINOR` changes add user-visible features and improvements.
- `PATCH` changes fix bugs, polish existing behavior, or make small internal improvements.

## [Unreleased]

### Improved

- Redesigned the menu bar extra as a window-style panel, and the menu bar icon now shows a live waveform driven by the input level while recording.

## [1.1.0] - 2026-06-03

### Added
Expand Down
58 changes: 51 additions & 7 deletions Sources/App.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ struct FreeFlowApp: App {
MenuBarLabel()
.environmentObject(appDelegate.appState)
}
.menuBarExtraStyle(.window)
}
}

Expand All @@ -22,28 +23,71 @@ struct MenuBarLabel: View {
@EnvironmentObject var appState: AppState
@ObservedObject var notificationManager = VocabularyNotificationManager.shared

private var iconName: String {
if appState.isRecording { return "record.circle" }
if appState.isTranscribing { return "ellipsis.circle" }
return "waveform"
}
/// Rolling window of recent input levels rendered as the live
/// menu bar waveform while recording.
@State private var levelHistory: [Float] = Array(repeating: 0, count: 5)

var body: some View {
HStack(spacing: 4) {
if notificationManager.showCheckmark {
Image(systemName: "checkmark")
}
if AppBuild.isDevBundle && !appState.isRecording && !appState.isTranscribing {
if appState.isRecording {
Image(nsImage: LiveWaveMenuBarIcon.image(levels: levelHistory))
.renderingMode(.template)
} else if appState.isTranscribing {
Image(systemName: "ellipsis.circle")
} else if AppBuild.isDevBundle {
Image(nsImage: StampedMenuBarIcon.templateImage)
.renderingMode(.template)
} else {
Image(systemName: iconName)
Image(systemName: "waveform")
}
}
.onChange(of: appState.menuBarAudioLevel) { level in
guard appState.isRecording else { return }
levelHistory.removeFirst()
levelHistory.append(level)
}
.onChange(of: appState.isRecording) { recording in
if !recording {
levelHistory = Array(repeating: 0, count: 5)
}
}
.animation(.easeInOut(duration: 0.2), value: notificationManager.showCheckmark)
}
}

/// Renders the recording-state menu bar icon: a bar per recent level
/// sample, so the icon itself moves with the user's voice. Template
/// image, so it stays legible in light and dark menu bars.
enum LiveWaveMenuBarIcon {
static func image(levels: [Float]) -> NSImage {
let size = NSSize(width: 18, height: 16)
let barWidth: CGFloat = 2
let spacing: CGFloat = 1.5
let image = NSImage(size: size, flipped: false) { rect in
let count = CGFloat(levels.count)
let totalWidth = count * barWidth + (count - 1) * spacing
var x = (rect.width - totalWidth) / 2
NSColor.black.setFill()
for level in levels {
let clamped = CGFloat(min(max(level, 0), 1))
let height = 3 + clamped * 10
let y = (rect.height - height) / 2
NSBezierPath(
roundedRect: NSRect(x: x, y: y, width: barWidth, height: height),
xRadius: barWidth / 2, yRadius: barWidth / 2
).fill()
x += barWidth + spacing
}
return true
}
image.isTemplate = true
return image
}
}

enum StampedMenuBarIcon {
static let templateImage: NSImage = {
let size = NSSize(width: 18, height: 16)
Expand Down
3 changes: 3 additions & 0 deletions Sources/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,8 @@ final class AppState: ObservableObject, @unchecked Sendable {
@Published var selectedSettingsTab: SettingsTab? = .general
@Published var pipelineHistory: [PipelineHistoryItem] = []
@Published var debugStatusMessage = "Idle"
/// Live input level mirrored to the menu bar icon while recording.
@Published var menuBarAudioLevel: Float = 0
@Published var debugShowsUpdateReminderAfterDictation = false
@Published var lastRawTranscript = ""
@Published var lastPostProcessedTranscript = ""
Expand Down Expand Up @@ -2207,6 +2209,7 @@ final class AppState: ObservableObject, @unchecked Sendable {
.receive(on: DispatchQueue.main)
.sink { [weak self] level in
self?.overlayManager.updateAudioLevel(level)
self?.menuBarAudioLevel = level
}
}
} catch {
Expand Down
Loading