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:
@@ -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 {
|
||||
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() }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user