diff --git a/macos/src/main.swift b/macos/src/main.swift index da969c7..2d2c325 100644 --- a/macos/src/main.swift +++ b/macos/src/main.swift @@ -15,6 +15,35 @@ struct Defaults { static let livePreview = "livePreview" } +// MARK: - Icons +// SF Symbols: vector icons that stay sharp at any scale. Template symbols are +// tinted by macOS (menu bar, menus, buttons) and follow light/dark appearance. +enum Icons { + static func symbol(_ name: String, pointSize: CGFloat = 13, weight: NSFont.Weight = .regular, description: String? = nil) -> NSImage? { + let image = NSImage(systemSymbolName: name, accessibilityDescription: description)? + .withSymbolConfiguration(.init(pointSize: pointSize, weight: weight)) + image?.isTemplate = true + return image + } + + // Fixed colour, for states that need it (recording) and for custom drawing (HUD). + // Tints the monochrome symbol so knockouts stay transparent (the tick in + // checkmark.circle.fill); a one-colour palette would fill them in. Drawn + // lazily, so it stays sharp on any display scale. + static func coloredSymbol(_ name: String, pointSize: CGFloat, weight: NSFont.Weight = .regular, color: NSColor, description: String? = nil) -> NSImage? { + guard let symbol = symbol(name, pointSize: pointSize, weight: weight, description: description) else { return nil } + let image = NSImage(size: symbol.size, flipped: false) { rect in + symbol.draw(in: rect) + color.set() + rect.fill(using: .sourceAtop) + return true + } + image.accessibilityDescription = description + image.isTemplate = false + return image + } +} + // MARK: - Whisper Models struct WhisperModels { struct Model { @@ -681,24 +710,40 @@ final class MeterView: NSView { } func reset() { level = 0; silent = false; mode = .recording; notice = nil; previewText = nil; needsDisplay = true } + // Draws a symbol centred on a text line drawn at `origin`; returns the x where the text starts + private static func drawSymbol(_ name: String, color: NSColor, font: NSFont, at origin: NSPoint) -> CGFloat { + guard let image = Icons.coloredSymbol(name, pointSize: font.pointSize, weight: .medium, color: color) else { return origin.x } + let lineHeight = ceil(font.ascender - font.descender) + image.draw(in: NSRect(x: origin.x, y: origin.y + (lineHeight - image.size.height) / 2, + width: image.size.width, height: image.size.height)) + return origin.x + image.size.width + 5 + } + override func draw(_ dirtyRect: NSRect) { let bg = NSBezierPath(roundedRect: bounds, xRadius: 12, yRadius: 12) NSColor(calibratedWhite: 0.10, alpha: 0.93).setFill() bg.fill() let warn = mode == .recording && silent - let label = mode == .transcribing ? "⏳ Transcribing…" : warn ? "🎤 No audio input?" : "🎤 Recording…" + let titleFont = NSFont.systemFont(ofSize: 12, weight: .medium) + let (symbol, symbolColor, label): (String, NSColor, String) = + mode == .transcribing ? ("hourglass", .white, "Transcribing…") + : warn ? ("mic.slash.fill", .systemRed, "No audio input?") + : ("mic.fill", .systemRed, "Recording…") var textY = bounds.height - 26 - label.draw(at: NSPoint(x: 16, y: textY), withAttributes: [ - .font: NSFont.systemFont(ofSize: 12, weight: .medium), + let titleX = MeterView.drawSymbol(symbol, color: symbolColor, font: titleFont, at: NSPoint(x: 16, y: textY)) + label.draw(at: NSPoint(x: titleX, y: textY), withAttributes: [ + .font: titleFont, .foregroundColor: warn ? NSColor.systemRed : NSColor.white ]) switch notice { case .loadingModel?: textY -= 19 - "Loading model into memory…".draw(at: NSPoint(x: 16, y: textY), withAttributes: [ - .font: NSFont.systemFont(ofSize: 11, weight: .semibold), + let font = NSFont.systemFont(ofSize: 11, weight: .semibold) + let x = MeterView.drawSymbol("memorychip", color: .systemYellow, font: font, at: NSPoint(x: 16, y: textY)) + "Loading model into memory…".draw(at: NSPoint(x: x, y: textY), withAttributes: [ + .font: font, .foregroundColor: NSColor.systemYellow ]) textY -= 16 @@ -708,8 +753,10 @@ final class MeterView: NSView { ]) case .modelLoaded?: textY -= 19 - "✓ Model loaded".draw(at: NSPoint(x: 16, y: textY), withAttributes: [ - .font: NSFont.systemFont(ofSize: 11, weight: .semibold), + let font = NSFont.systemFont(ofSize: 11, weight: .semibold) + let x = MeterView.drawSymbol("checkmark.circle.fill", color: .systemGreen, font: font, at: NSPoint(x: 16, y: textY)) + "Model loaded".draw(at: NSPoint(x: x, y: textY), withAttributes: [ + .font: font, .foregroundColor: NSColor.systemGreen ]) case nil: @@ -818,6 +865,7 @@ final class DownloadProgressWindow: NSObject { private var phase = Phase.downloading private var window: NSWindow? + private let titleIcon = NSImageView() private let titleLabel = NSTextField(labelWithString: "") // Two indicators instead of toggling isIndeterminate on one: after an // indeterminate phase, NSProgressIndicator stops drawing determinate values. @@ -871,6 +919,7 @@ final class DownloadProgressWindow: NSObject { bytesPerSecond = 0 lastRender = .distantPast titleLabel.stringValue = "Downloading \(modelName)" + setTitleIcon("arrow.down.circle.fill", color: .controlAccentColor) setBar(indeterminate: true) setDetail("Connecting…") hintLabel.isHidden = false @@ -916,6 +965,7 @@ final class DownloadProgressWindow: NSObject { func showLoading(modelName: String) { phase = .loading titleLabel.stringValue = "Loading \(modelName)…" + setTitleIcon("memorychip.fill", color: .controlAccentColor) setBar(indeterminate: true) setDetail("Download complete. Loading the model into memory…") hintLabel.isHidden = true @@ -924,7 +974,8 @@ final class DownloadProgressWindow: NSObject { func showReady(modelName: String) { phase = .ready - titleLabel.stringValue = "✓ \(modelName) is ready" + titleLabel.stringValue = "\(modelName) is ready" + setTitleIcon("checkmark.circle.fill", color: .systemGreen) setBar(indeterminate: false, value: 1) setDetail("It is now the active model. Press ⌃⌥D to dictate.") hintLabel.isHidden = true @@ -933,7 +984,8 @@ final class DownloadProgressWindow: NSObject { func showFailed(modelName: String, message: String) { phase = .failed - titleLabel.stringValue = "⚠️ Could not download \(modelName)" + titleLabel.stringValue = "Could not download \(modelName)" + setTitleIcon("exclamationmark.triangle.fill", color: .systemYellow) busyBar.stopAnimation(nil) busyBar.isHidden = true bar.isHidden = true @@ -951,7 +1003,11 @@ final class DownloadProgressWindow: NSObject { let v = NSView(frame: NSRect(x: 0, y: 0, width: 440, height: 170)) - titleLabel.frame = NSRect(x: 20, y: 130, width: 400, height: 20) + titleIcon.frame = NSRect(x: 20, y: 131, width: 18, height: 18) + titleIcon.symbolConfiguration = .init(pointSize: 14, weight: .semibold) + v.addSubview(titleIcon) + + titleLabel.frame = NSRect(x: 44, y: 130, width: 376, height: 20) titleLabel.font = .boldSystemFont(ofSize: 13) titleLabel.lineBreakMode = .byTruncatingTail v.addSubview(titleLabel) @@ -995,6 +1051,11 @@ final class DownloadProgressWindow: NSObject { return w } + private func setTitleIcon(_ name: String, color: NSColor) { + titleIcon.image = NSImage(systemSymbolName: name, accessibilityDescription: nil) + titleIcon.contentTintColor = color + } + private func setBar(indeterminate: Bool, value: Double = 0) { bar.isHidden = indeterminate busyBar.isHidden = !indeterminate @@ -1162,7 +1223,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate, if alert.runModal() == .alertFirstButtonReturn { downloadModel(WhisperModels.available[popup.indexOfSelectedItem]) } else { - updateStatus("⚠️ No model selected") + updateStatus("No model selected", warning: true) } } @@ -1180,7 +1241,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate, return } guard let url = URL(string: model.url) else { - updateStatus("⚠️ Invalid URL") + updateStatus("Invalid URL", warning: true) return } @@ -1224,7 +1285,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate, func failDownload(_ message: String) { let name = currentDownloadModel?.name ?? "model" clearDownloadState() - updateStatus("⚠️ Download failed") + updateStatus("Download failed", warning: true) if playSounds { NSSound(named: "Basso")?.play() } downloadWindow.showFailed(modelName: name, message: message) downloadWindow.show() @@ -1237,24 +1298,43 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate, updateDownloadIndicators() } - // Menu bar icon when idle: download progress while a model downloads, else the mic - var idleIcon: String { - downloadTask != nil ? "⬇️ \(downloadPercent)%" : "🎤" + // Menu bar icon. While idle it shows the download progress if a model is downloading. + enum MenuBarIcon { case idle, recording, busy } + var menuBarIcon = MenuBarIcon.idle { didSet { refreshMenuBarIcon() } } + + func refreshMenuBarIcon() { + guard let button = statusItem?.button else { return } + let downloading = menuBarIcon == .idle && downloadTask != nil + switch menuBarIcon { + case .idle: + button.image = downloading + ? Icons.symbol("arrow.down.circle", pointSize: 15, description: "WhisperDictate: downloading a model") + : Icons.symbol("mic", pointSize: 15, description: "WhisperDictate") + case .recording: + button.image = Icons.coloredSymbol("mic.fill", pointSize: 15, color: .systemRed, description: "WhisperDictate: recording") + case .busy: + button.image = Icons.symbol("hourglass", pointSize: 15, description: "WhisperDictate: working") + } + button.imagePosition = .imageLeading + button.title = downloading ? "\(downloadPercent)%" : "" } // Sync the menu bar icon, the menu's download line and the Settings button // with the download state, without overriding the recording/transcribing icon. func updateDownloadIndicators() { - if let title = statusItem.button?.title, title == "🎤" || title.hasPrefix("⬇️") { - statusItem.button?.title = idleIcon - } + refreshMenuBarIcon() if let item = statusItem.menu?.item(withTag: 101) { item.isHidden = currentDownloadModel == nil if let model = currentDownloadModel { - item.title = "⬇️ Downloading \(model.name): \(downloadPercent)% — Show Progress…" + item.title = "Downloading \(model.name): \(downloadPercent)% — Show Progress…" } } - settingsDownloadButton?.title = downloadTask != nil ? "⬇️ \(downloadPercent)%" : "Download..." + if let button = settingsDownloadButton { + let downloading = downloadTask != nil + button.image = downloading ? Icons.symbol("arrow.down.circle", pointSize: 11) : nil + button.imagePosition = .imageLeading + button.title = downloading ? "\(downloadPercent)%" : "Download..." + } } // MARK: - URLSessionDownloadDelegate @@ -1324,7 +1404,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate, // MARK: - Status Item func setupStatusItem() { statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) - statusItem.button?.title = "🎤" + refreshMenuBarIcon() let menu = NSMenu() @@ -1336,6 +1416,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate, menu.addItem(statusMenuItem) let downloadMenuItem = NSMenuItem(title: "", action: #selector(showDownloadWindow), keyEquivalent: "") + downloadMenuItem.image = Icons.symbol("arrow.down.circle") downloadMenuItem.target = self downloadMenuItem.tag = 101 downloadMenuItem.isHidden = true @@ -1734,20 +1815,16 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate, func checkModelExists() { let validation = isValidModelPath(modelPath) if !validation.valid { - updateStatus("⚠️ \(validation.error ?? "Invalid model")") + updateStatus(validation.error ?? "Invalid model", warning: true) } else { updateStatus("Ready") } } - func updateStatus(_ status: String) { - if let menu = statusItem.menu { - for item in menu.items { - if item.tag == 100 { - item.title = "Status: \(status)" - } - } - } + func updateStatus(_ status: String, warning: Bool = false) { + guard let item = statusItem.menu?.item(withTag: 100) else { return } + item.title = "Status: \(status)" + item.image = warning ? Icons.coloredSymbol("exclamationmark.triangle.fill", pointSize: 13, color: .systemYellow) : nil } // MARK: - Permissions @@ -1840,7 +1917,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate, audioRecorder?.isMeteringEnabled = true audioRecorder?.record() isRecording = true - statusItem.button?.title = "🔴" + menuBarIcon = .recording updateStatus("Recording...") if playSounds { NSSound(named: "Tink")?.play() } NSLog("Recording started") @@ -1961,7 +2038,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate, let keepHUD = explainLoading || recordingHUD.hasPreview stopMeter(hideHUD: !keepHUD) if keepHUD { recordingHUD.showTranscribing(notice: explainLoading ? .loadingModel : nil) } - statusItem.button?.title = "⏳" + menuBarIcon = .busy updateStatus("Transcribing...") if playSounds { NSSound(named: "Pop")?.play() } NSLog("Recording stopped, transcribing...") @@ -2031,7 +2108,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate, } if !isRecording && !isTranscribing { - statusItem.button?.title = "⏳" + menuBarIcon = .busy updateStatus("Loading model…") } @@ -2043,7 +2120,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate, defer { completion?() } guard let self = self, self.whisperServer === server else { return } let busy = self.isRecording || self.isTranscribing - if !busy { self.statusItem.button?.title = self.idleIcon } + if !busy { self.menuBarIcon = .idle } if ok { if !busy { self.updateStatus("Ready") } NSLog("whisper-server ready on port \(server.port)") @@ -2061,8 +2138,8 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate, let modelValidation = isValidModelPath(modelPath) guard modelValidation.valid else { DispatchQueue.main.async { - self.statusItem.button?.title = self.idleIcon - self.updateStatus("⚠️ \(modelValidation.error ?? "Invalid model")") + self.menuBarIcon = .idle + self.updateStatus(modelValidation.error ?? "Invalid model", warning: true) if self.playSounds { NSSound(named: "Basso")?.play() } } return @@ -2070,8 +2147,8 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate, guard SupportedLanguages.isValid(language) else { DispatchQueue.main.async { - self.statusItem.button?.title = self.idleIcon - self.updateStatus("⚠️ Invalid language") + self.menuBarIcon = .idle + self.updateStatus("Invalid language", warning: true) if self.playSounds { NSSound(named: "Basso")?.play() } } return @@ -2098,7 +2175,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate, if let finalText = finalText, !finalText.isEmpty { self.pasteText(finalText) } else { - self.statusItem.button?.title = self.idleIcon + self.menuBarIcon = .idle self.updateStatus("Ready") if self.playSounds { NSSound(named: "Basso")?.play() } NSLog("No speech recognized") @@ -2111,8 +2188,8 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate, func transcribeWithCLI(prompt: String? = nil) -> String? { guard let whisperPath = findWhisperCLI() else { DispatchQueue.main.async { - self.statusItem.button?.title = self.idleIcon - self.updateStatus("⚠️ whisper-cli not found") + self.menuBarIcon = .idle + self.updateStatus("whisper-cli not found", warning: true) if self.playSounds { NSSound(named: "Basso")?.play() } NSLog("whisper-cli not found in PATH") } @@ -2187,7 +2264,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate, } } - self.statusItem.button?.title = self.idleIcon + self.menuBarIcon = .idle self.updateStatus("Ready") if self.playSounds { NSSound(named: "Glass")?.play() } }