From 6b6d9342a9cb66154059913edd17dffe4db0ebc7 Mon Sep 17 00:00:00 2001 From: hariel1985 Date: Wed, 16 Sep 2026 14:17:44 +0200 Subject: [PATCH] Replace emoji icons with SF Symbols MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The menu bar, recording HUD, download window, menus and Settings used emoji as icons. They now use SF Symbols: vector, crisp at any scale, and template-rendered in the menu bar so they follow light, dark and tinted menu bars. Menu bar: mic (idle), red mic.fill (recording), hourglass (transcribing / loading the model), arrow.down.circle with the percentage (downloading). The icon is now driven by a state (idle/recording/busy) instead of comparing emoji strings, so download progress can't overwrite the recording indicator. Icons carry accessibility descriptions. Coloured symbols are tinted by drawing the monochrome symbol, which keeps knockouts transparent (a one-colour palette filled in the tick of checkmark.circle.fill). Status warnings take a warning flag and show a yellow triangle instead of a "⚠️" prefix. Co-Authored-By: Claude Opus 5 --- macos/src/main.swift | 163 +++++++++++++++++++++++++++++++------------ 1 file changed, 120 insertions(+), 43 deletions(-) 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() } }