Replace emoji icons with SF Symbols

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 <noreply@anthropic.com>
This commit is contained in:
hariel1985
2026-09-16 14:17:44 +02:00
szülő 78caac8d04
commit 6b6d9342a9

Fájl megtekintése

@@ -15,6 +15,35 @@ struct Defaults {
static let livePreview = "livePreview" 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 // MARK: - Whisper Models
struct WhisperModels { struct WhisperModels {
struct Model { struct Model {
@@ -681,24 +710,40 @@ final class MeterView: NSView {
} }
func reset() { level = 0; silent = false; mode = .recording; notice = nil; previewText = nil; needsDisplay = true } 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) { override func draw(_ dirtyRect: NSRect) {
let bg = NSBezierPath(roundedRect: bounds, xRadius: 12, yRadius: 12) let bg = NSBezierPath(roundedRect: bounds, xRadius: 12, yRadius: 12)
NSColor(calibratedWhite: 0.10, alpha: 0.93).setFill() NSColor(calibratedWhite: 0.10, alpha: 0.93).setFill()
bg.fill() bg.fill()
let warn = mode == .recording && silent 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 var textY = bounds.height - 26
label.draw(at: NSPoint(x: 16, y: textY), withAttributes: [ let titleX = MeterView.drawSymbol(symbol, color: symbolColor, font: titleFont, at: NSPoint(x: 16, y: textY))
.font: NSFont.systemFont(ofSize: 12, weight: .medium), label.draw(at: NSPoint(x: titleX, y: textY), withAttributes: [
.font: titleFont,
.foregroundColor: warn ? NSColor.systemRed : NSColor.white .foregroundColor: warn ? NSColor.systemRed : NSColor.white
]) ])
switch notice { switch notice {
case .loadingModel?: case .loadingModel?:
textY -= 19 textY -= 19
"Loading model into memory…".draw(at: NSPoint(x: 16, y: textY), withAttributes: [ let font = NSFont.systemFont(ofSize: 11, weight: .semibold)
.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 .foregroundColor: NSColor.systemYellow
]) ])
textY -= 16 textY -= 16
@@ -708,8 +753,10 @@ final class MeterView: NSView {
]) ])
case .modelLoaded?: case .modelLoaded?:
textY -= 19 textY -= 19
"✓ Model loaded".draw(at: NSPoint(x: 16, y: textY), withAttributes: [ let font = NSFont.systemFont(ofSize: 11, weight: .semibold)
.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 .foregroundColor: NSColor.systemGreen
]) ])
case nil: case nil:
@@ -818,6 +865,7 @@ final class DownloadProgressWindow: NSObject {
private var phase = Phase.downloading private var phase = Phase.downloading
private var window: NSWindow? private var window: NSWindow?
private let titleIcon = NSImageView()
private let titleLabel = NSTextField(labelWithString: "") private let titleLabel = NSTextField(labelWithString: "")
// Two indicators instead of toggling isIndeterminate on one: after an // Two indicators instead of toggling isIndeterminate on one: after an
// indeterminate phase, NSProgressIndicator stops drawing determinate values. // indeterminate phase, NSProgressIndicator stops drawing determinate values.
@@ -871,6 +919,7 @@ final class DownloadProgressWindow: NSObject {
bytesPerSecond = 0 bytesPerSecond = 0
lastRender = .distantPast lastRender = .distantPast
titleLabel.stringValue = "Downloading \(modelName)" titleLabel.stringValue = "Downloading \(modelName)"
setTitleIcon("arrow.down.circle.fill", color: .controlAccentColor)
setBar(indeterminate: true) setBar(indeterminate: true)
setDetail("Connecting…") setDetail("Connecting…")
hintLabel.isHidden = false hintLabel.isHidden = false
@@ -916,6 +965,7 @@ final class DownloadProgressWindow: NSObject {
func showLoading(modelName: String) { func showLoading(modelName: String) {
phase = .loading phase = .loading
titleLabel.stringValue = "Loading \(modelName)…" titleLabel.stringValue = "Loading \(modelName)…"
setTitleIcon("memorychip.fill", color: .controlAccentColor)
setBar(indeterminate: true) setBar(indeterminate: true)
setDetail("Download complete. Loading the model into memory…") setDetail("Download complete. Loading the model into memory…")
hintLabel.isHidden = true hintLabel.isHidden = true
@@ -924,7 +974,8 @@ final class DownloadProgressWindow: NSObject {
func showReady(modelName: String) { func showReady(modelName: String) {
phase = .ready phase = .ready
titleLabel.stringValue = "✓ \(modelName) is ready" titleLabel.stringValue = "\(modelName) is ready"
setTitleIcon("checkmark.circle.fill", color: .systemGreen)
setBar(indeterminate: false, value: 1) setBar(indeterminate: false, value: 1)
setDetail("It is now the active model. Press ⌃⌥D to dictate.") setDetail("It is now the active model. Press ⌃⌥D to dictate.")
hintLabel.isHidden = true hintLabel.isHidden = true
@@ -933,7 +984,8 @@ final class DownloadProgressWindow: NSObject {
func showFailed(modelName: String, message: String) { func showFailed(modelName: String, message: String) {
phase = .failed phase = .failed
titleLabel.stringValue = "⚠️ Could not download \(modelName)" titleLabel.stringValue = "Could not download \(modelName)"
setTitleIcon("exclamationmark.triangle.fill", color: .systemYellow)
busyBar.stopAnimation(nil) busyBar.stopAnimation(nil)
busyBar.isHidden = true busyBar.isHidden = true
bar.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)) 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.font = .boldSystemFont(ofSize: 13)
titleLabel.lineBreakMode = .byTruncatingTail titleLabel.lineBreakMode = .byTruncatingTail
v.addSubview(titleLabel) v.addSubview(titleLabel)
@@ -995,6 +1051,11 @@ final class DownloadProgressWindow: NSObject {
return w 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) { private func setBar(indeterminate: Bool, value: Double = 0) {
bar.isHidden = indeterminate bar.isHidden = indeterminate
busyBar.isHidden = !indeterminate busyBar.isHidden = !indeterminate
@@ -1162,7 +1223,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate,
if alert.runModal() == .alertFirstButtonReturn { if alert.runModal() == .alertFirstButtonReturn {
downloadModel(WhisperModels.available[popup.indexOfSelectedItem]) downloadModel(WhisperModels.available[popup.indexOfSelectedItem])
} else { } else {
updateStatus("⚠️ No model selected") updateStatus("No model selected", warning: true)
} }
} }
@@ -1180,7 +1241,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate,
return return
} }
guard let url = URL(string: model.url) else { guard let url = URL(string: model.url) else {
updateStatus("⚠️ Invalid URL") updateStatus("Invalid URL", warning: true)
return return
} }
@@ -1224,7 +1285,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate,
func failDownload(_ message: String) { func failDownload(_ message: String) {
let name = currentDownloadModel?.name ?? "model" let name = currentDownloadModel?.name ?? "model"
clearDownloadState() clearDownloadState()
updateStatus("⚠️ Download failed") updateStatus("Download failed", warning: true)
if playSounds { NSSound(named: "Basso")?.play() } if playSounds { NSSound(named: "Basso")?.play() }
downloadWindow.showFailed(modelName: name, message: message) downloadWindow.showFailed(modelName: name, message: message)
downloadWindow.show() downloadWindow.show()
@@ -1237,24 +1298,43 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate,
updateDownloadIndicators() updateDownloadIndicators()
} }
// Menu bar icon when idle: download progress while a model downloads, else the mic // Menu bar icon. While idle it shows the download progress if a model is downloading.
var idleIcon: String { enum MenuBarIcon { case idle, recording, busy }
downloadTask != nil ? "⬇️ \(downloadPercent)%" : "🎤" 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 // Sync the menu bar icon, the menu's download line and the Settings button
// with the download state, without overriding the recording/transcribing icon. // with the download state, without overriding the recording/transcribing icon.
func updateDownloadIndicators() { func updateDownloadIndicators() {
if let title = statusItem.button?.title, title == "🎤" || title.hasPrefix("⬇️") { refreshMenuBarIcon()
statusItem.button?.title = idleIcon
}
if let item = statusItem.menu?.item(withTag: 101) { if let item = statusItem.menu?.item(withTag: 101) {
item.isHidden = currentDownloadModel == nil item.isHidden = currentDownloadModel == nil
if let model = currentDownloadModel { 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 // MARK: - URLSessionDownloadDelegate
@@ -1324,7 +1404,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate,
// MARK: - Status Item // MARK: - Status Item
func setupStatusItem() { func setupStatusItem() {
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
statusItem.button?.title = "🎤" refreshMenuBarIcon()
let menu = NSMenu() let menu = NSMenu()
@@ -1336,6 +1416,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate,
menu.addItem(statusMenuItem) menu.addItem(statusMenuItem)
let downloadMenuItem = NSMenuItem(title: "", action: #selector(showDownloadWindow), keyEquivalent: "") let downloadMenuItem = NSMenuItem(title: "", action: #selector(showDownloadWindow), keyEquivalent: "")
downloadMenuItem.image = Icons.symbol("arrow.down.circle")
downloadMenuItem.target = self downloadMenuItem.target = self
downloadMenuItem.tag = 101 downloadMenuItem.tag = 101
downloadMenuItem.isHidden = true downloadMenuItem.isHidden = true
@@ -1734,20 +1815,16 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate,
func checkModelExists() { func checkModelExists() {
let validation = isValidModelPath(modelPath) let validation = isValidModelPath(modelPath)
if !validation.valid { if !validation.valid {
updateStatus("⚠️ \(validation.error ?? "Invalid model")") updateStatus(validation.error ?? "Invalid model", warning: true)
} else { } else {
updateStatus("Ready") updateStatus("Ready")
} }
} }
func updateStatus(_ status: String) { func updateStatus(_ status: String, warning: Bool = false) {
if let menu = statusItem.menu { guard let item = statusItem.menu?.item(withTag: 100) else { return }
for item in menu.items { item.title = "Status: \(status)"
if item.tag == 100 { item.image = warning ? Icons.coloredSymbol("exclamationmark.triangle.fill", pointSize: 13, color: .systemYellow) : nil
item.title = "Status: \(status)"
}
}
}
} }
// MARK: - Permissions // MARK: - Permissions
@@ -1840,7 +1917,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate,
audioRecorder?.isMeteringEnabled = true audioRecorder?.isMeteringEnabled = true
audioRecorder?.record() audioRecorder?.record()
isRecording = true isRecording = true
statusItem.button?.title = "🔴" menuBarIcon = .recording
updateStatus("Recording...") updateStatus("Recording...")
if playSounds { NSSound(named: "Tink")?.play() } if playSounds { NSSound(named: "Tink")?.play() }
NSLog("Recording started") NSLog("Recording started")
@@ -1961,7 +2038,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate,
let keepHUD = explainLoading || recordingHUD.hasPreview let keepHUD = explainLoading || recordingHUD.hasPreview
stopMeter(hideHUD: !keepHUD) stopMeter(hideHUD: !keepHUD)
if keepHUD { recordingHUD.showTranscribing(notice: explainLoading ? .loadingModel : nil) } if keepHUD { recordingHUD.showTranscribing(notice: explainLoading ? .loadingModel : nil) }
statusItem.button?.title = "⏳" menuBarIcon = .busy
updateStatus("Transcribing...") updateStatus("Transcribing...")
if playSounds { NSSound(named: "Pop")?.play() } if playSounds { NSSound(named: "Pop")?.play() }
NSLog("Recording stopped, transcribing...") NSLog("Recording stopped, transcribing...")
@@ -2031,7 +2108,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate,
} }
if !isRecording && !isTranscribing { if !isRecording && !isTranscribing {
statusItem.button?.title = "⏳" menuBarIcon = .busy
updateStatus("Loading model…") updateStatus("Loading model…")
} }
@@ -2043,7 +2120,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate,
defer { completion?() } defer { completion?() }
guard let self = self, self.whisperServer === server else { return } guard let self = self, self.whisperServer === server else { return }
let busy = self.isRecording || self.isTranscribing let busy = self.isRecording || self.isTranscribing
if !busy { self.statusItem.button?.title = self.idleIcon } if !busy { self.menuBarIcon = .idle }
if ok { if ok {
if !busy { self.updateStatus("Ready") } if !busy { self.updateStatus("Ready") }
NSLog("whisper-server ready on port \(server.port)") NSLog("whisper-server ready on port \(server.port)")
@@ -2061,8 +2138,8 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate,
let modelValidation = isValidModelPath(modelPath) let modelValidation = isValidModelPath(modelPath)
guard modelValidation.valid else { guard modelValidation.valid else {
DispatchQueue.main.async { DispatchQueue.main.async {
self.statusItem.button?.title = self.idleIcon self.menuBarIcon = .idle
self.updateStatus("⚠️ \(modelValidation.error ?? "Invalid model")") self.updateStatus(modelValidation.error ?? "Invalid model", warning: true)
if self.playSounds { NSSound(named: "Basso")?.play() } if self.playSounds { NSSound(named: "Basso")?.play() }
} }
return return
@@ -2070,8 +2147,8 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate,
guard SupportedLanguages.isValid(language) else { guard SupportedLanguages.isValid(language) else {
DispatchQueue.main.async { DispatchQueue.main.async {
self.statusItem.button?.title = self.idleIcon self.menuBarIcon = .idle
self.updateStatus("⚠️ Invalid language") self.updateStatus("Invalid language", warning: true)
if self.playSounds { NSSound(named: "Basso")?.play() } if self.playSounds { NSSound(named: "Basso")?.play() }
} }
return return
@@ -2098,7 +2175,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate,
if let finalText = finalText, !finalText.isEmpty { if let finalText = finalText, !finalText.isEmpty {
self.pasteText(finalText) self.pasteText(finalText)
} else { } else {
self.statusItem.button?.title = self.idleIcon self.menuBarIcon = .idle
self.updateStatus("Ready") self.updateStatus("Ready")
if self.playSounds { NSSound(named: "Basso")?.play() } if self.playSounds { NSSound(named: "Basso")?.play() }
NSLog("No speech recognized") NSLog("No speech recognized")
@@ -2111,8 +2188,8 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate,
func transcribeWithCLI(prompt: String? = nil) -> String? { func transcribeWithCLI(prompt: String? = nil) -> String? {
guard let whisperPath = findWhisperCLI() else { guard let whisperPath = findWhisperCLI() else {
DispatchQueue.main.async { DispatchQueue.main.async {
self.statusItem.button?.title = self.idleIcon self.menuBarIcon = .idle
self.updateStatus("⚠️ whisper-cli not found") self.updateStatus("whisper-cli not found", warning: true)
if self.playSounds { NSSound(named: "Basso")?.play() } if self.playSounds { NSSound(named: "Basso")?.play() }
NSLog("whisper-cli not found in PATH") 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") self.updateStatus("Ready")
if self.playSounds { NSSound(named: "Glass")?.play() } if self.playSounds { NSSound(named: "Glass")?.play() }
} }