Skip to content
Closed
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
97 changes: 66 additions & 31 deletions Sources/App.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,53 +23,88 @@ 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"
}

var body: some View {
HStack(spacing: 4) {
if notificationManager.showCheckmark {
Image(systemName: "checkmark")
}
if AppBuild.isDevBundle && !appState.isRecording && !appState.isTranscribing {
Image(nsImage: StampedMenuBarIcon.templateImage)
.renderingMode(.template)
if appState.isRecording {
Image(systemName: "record.circle")
} else if appState.isTranscribing {
Image(systemName: "ellipsis.circle")
} else {
Image(systemName: iconName)
// Idle: the Fluent brand glyph (speech bubble + waveform).
// Dev builds get a small marker dot so they're easy to tell
// apart from a release build running side by side.
Image(nsImage: AppBuild.isDevBundle
? FluentMenuBarIcon.devTemplateImage
: FluentMenuBarIcon.templateImage)
.renderingMode(.template)
}
}
.animation(.easeInOut(duration: 0.2), value: notificationManager.showCheckmark)
}
}

enum StampedMenuBarIcon {
static let templateImage: NSImage = {
/// The menu bar status icon, drawn as a monochrome template so macOS can tint
/// it correctly for light and dark menu bars. It is a line-art interpretation
/// of the Fluent app icon: a speech-bubble ring with a flowing waveform inside.
/// The app icon (`AppIcon.icns`) is the full-color dock/Finder icon and is not
/// used here, because menu bar items should be simple monochrome glyphs.
enum FluentMenuBarIcon {
static let templateImage: NSImage = makeImage(markDev: false)
static let devTemplateImage: NSImage = makeImage(markDev: true)

private static func makeImage(markDev: Bool) -> NSImage {
let size = NSSize(width: 18, height: 16)
let image = NSImage(size: size, flipped: false) { rect in
let path = NSBezierPath()
path.windingRule = .evenOdd
path.append(NSBezierPath(roundedRect: rect, xRadius: 3, yRadius: 3))
let bars: [(x: CGFloat, y: CGFloat, h: CGFloat)] = [
(3.0, 7.0, 2.0),
(5.5, 5.0, 6.0),
(8.0, 3.0, 10.0),
(10.5, 4.0, 8.0),
(13.0, 6.0, 4.0),
]
for bar in bars {
path.append(NSBezierPath(
roundedRect: NSRect(x: bar.x, y: bar.y, width: 1.5, height: bar.h),
xRadius: 0.75, yRadius: 0.75
))
}
let image = NSImage(size: size, flipped: false) { _ in
NSColor.black.setStroke()
NSColor.black.setFill()
path.fill()

let center = NSPoint(x: 8.8, y: 8.7)
let radius: CGFloat = 6.0

// Speech-bubble ring, left slightly open at the lower-left so the
// tail can flow out of it like the logo.
let ring = NSBezierPath()
ring.appendArc(withCenter: center, radius: radius,
startAngle: 232, endAngle: 200, clockwise: false)
ring.lineWidth = 1.5
ring.lineCapStyle = .round
ring.stroke()

// Tail: a short flick out of the lower-left of the bubble.
let tail = NSBezierPath()
tail.move(to: NSPoint(x: 4.7, y: 4.3))
tail.curve(to: NSPoint(x: 2.7, y: 2.4),
controlPoint1: NSPoint(x: 4.0, y: 3.4),
controlPoint2: NSPoint(x: 3.5, y: 2.8))
tail.lineWidth = 1.5
tail.lineCapStyle = .round
tail.stroke()

// Waveform squiggle across the middle, echoing the logo's flowing
// line: a low start, rising to a tall central peak, then easing down.
let wave = NSBezierPath()
wave.move(to: NSPoint(x: 5.0, y: 7.9))
wave.line(to: NSPoint(x: 6.6, y: 10.4))
wave.line(to: NSPoint(x: 7.9, y: 8.0))
wave.line(to: NSPoint(x: 9.2, y: 11.1))
wave.line(to: NSPoint(x: 10.7, y: 8.0))
wave.line(to: NSPoint(x: 12.6, y: 9.6))
wave.lineWidth = 1.5
wave.lineJoinStyle = .round
wave.lineCapStyle = .round
wave.stroke()

if markDev {
// Small filled dot at the top-right to denote the dev build.
NSBezierPath(ovalIn: NSRect(x: 14.0, y: 12.0, width: 3.2, height: 3.2)).fill()
}

return true
}
image.isTemplate = true
return image
}()
}
}