Control Center items sat in the same grid as third-party app icons, which
was hard to scan. Group them so Apple's Wi-Fi, Battery, Clock and friends
are listed separately from your apps' icons.
💘 Generated with Crush
Assisted-by: Crush:deepseek-v4.1-flash
329 lines
12 KiB
Swift
329 lines
12 KiB
Swift
import SwiftUI
|
|
import AppKit
|
|
|
|
/// The top bar panel shown from the menu bar hamburger icon.
|
|
struct TopBarView: View {
|
|
@ObservedObject var model: AppListModel
|
|
@ObservedObject var menuBar: MenuBarItemManager
|
|
@ObservedObject var hider: MenuBarHider
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
header
|
|
Divider()
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
menuBarSection
|
|
|
|
Divider().padding(.vertical, 4)
|
|
|
|
section(title: "Apps", apps: model.regularApps, emptyText: "No apps running")
|
|
|
|
Divider().padding(.vertical, 4)
|
|
|
|
backgroundSection
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
.frame(maxHeight: 420)
|
|
|
|
Divider()
|
|
footer
|
|
}
|
|
.frame(width: 320)
|
|
.onAppear {
|
|
model.refresh()
|
|
menuBar.refresh()
|
|
}
|
|
}
|
|
|
|
private var header: some View {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "line.3.horizontal")
|
|
.foregroundStyle(.secondary)
|
|
Text("SimpleTopBar")
|
|
.font(.headline)
|
|
Spacer()
|
|
Button {
|
|
model.refresh()
|
|
menuBar.refresh()
|
|
} label: {
|
|
Image(systemName: "arrow.clockwise")
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help("Refresh")
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 8)
|
|
}
|
|
|
|
/// The hamburger tray: every other app's menu bar item lives here.
|
|
private var menuBarSection: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
if !menuBar.authorized {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text("Grant Accessibility to collect and open menu bar items.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
Button("Grant Accessibility…") {
|
|
AccessibilityPermission.request()
|
|
menuBar.refresh()
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.controlSize(.small)
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 4)
|
|
} else if menuBar.items.isEmpty {
|
|
Text("No menu bar items found")
|
|
.font(.caption)
|
|
.foregroundStyle(.tertiary)
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 4)
|
|
} else {
|
|
trayGroup(title: "Apps", items: appTrayItems)
|
|
Divider().padding(.vertical, 2)
|
|
trayGroup(title: "System", items: systemTrayItems)
|
|
}
|
|
|
|
if menuBar.authorized {
|
|
Toggle(isOn: $hider.enabled) {
|
|
Text("Hide items from the menu bar")
|
|
.font(.caption)
|
|
}
|
|
.toggleStyle(.switch)
|
|
.controlSize(.mini)
|
|
.padding(.horizontal, 12)
|
|
.padding(.top, 6)
|
|
.help("Best-effort: pushes other apps' icons off the menu bar so they only live here. Apple's Wi-Fi, Spotlight, Clock, Battery and Control Center stay visible; macOS won't let any third-party app move them.")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Third-party apps' menu bar icons.
|
|
private var appTrayItems: [MenuBarItem] {
|
|
menuBar.items.filter { !$0.isSystem }
|
|
}
|
|
|
|
/// Apple's own menu bar items (Control Center, Spotlight, …).
|
|
private var systemTrayItems: [MenuBarItem] {
|
|
menuBar.items.filter { $0.isSystem }
|
|
}
|
|
|
|
private func trayGroup(title: String, items: [MenuBarItem]) -> some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
HStack(spacing: 6) {
|
|
Text(title.uppercased())
|
|
.font(.caption2)
|
|
.fontWeight(.semibold)
|
|
.foregroundStyle(.secondary)
|
|
Spacer()
|
|
Text("\(items.count)")
|
|
.font(.caption2)
|
|
.foregroundStyle(.tertiary)
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 1)
|
|
.background(Capsule().fill(Color.secondary.opacity(0.15)))
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.top, 4)
|
|
.padding(.bottom, 4)
|
|
|
|
if items.isEmpty {
|
|
Text("None")
|
|
.font(.caption)
|
|
.foregroundStyle(.tertiary)
|
|
.padding(.horizontal, 12)
|
|
.padding(.bottom, 4)
|
|
} else {
|
|
LazyVGrid(columns: [GridItem(.adaptive(minimum: 34), spacing: 6)], spacing: 6) {
|
|
ForEach(items) { item in
|
|
MenuBarTile(item: item, menuBar: menuBar)
|
|
}
|
|
}
|
|
.padding(.horizontal, 12)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func section(title: String, apps: [RunningApp], emptyText: String) -> some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
Text(title.uppercased())
|
|
.font(.caption2)
|
|
.fontWeight(.semibold)
|
|
.foregroundStyle(.secondary)
|
|
.padding(.horizontal, 12)
|
|
.padding(.top, 4)
|
|
.padding(.bottom, 2)
|
|
|
|
if apps.isEmpty {
|
|
Text(emptyText)
|
|
.font(.caption)
|
|
.foregroundStyle(.tertiary)
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 4)
|
|
} else {
|
|
ForEach(apps) { app in
|
|
AppRow(app: app, model: model)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Collapsible dropdown for background apps. When collapsed it is a single
|
|
/// header row; when expanded it reveals a limited number of apps and hides
|
|
/// the overflow behind "Show N more".
|
|
private var backgroundSection: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
Button {
|
|
model.toggleBackgroundExpanded()
|
|
} label: {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: model.backgroundExpanded ? "chevron.down" : "chevron.right")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
.frame(width: 10)
|
|
Text("Background Apps")
|
|
.font(.caption2)
|
|
.fontWeight(.semibold)
|
|
.foregroundStyle(.secondary)
|
|
Spacer()
|
|
Text("\(model.backgroundApps.count)")
|
|
.font(.caption2)
|
|
.foregroundStyle(.tertiary)
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 1)
|
|
.background(Capsule().fill(Color.secondary.opacity(0.15)))
|
|
}
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 4)
|
|
.help(model.backgroundExpanded ? "Collapse background apps" : "Expand background apps")
|
|
|
|
if model.backgroundExpanded {
|
|
if model.backgroundApps.isEmpty {
|
|
Text("No background apps")
|
|
.font(.caption)
|
|
.foregroundStyle(.tertiary)
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 4)
|
|
} else {
|
|
ForEach(model.visibleBackgroundApps) { app in
|
|
AppRow(app: app, model: model)
|
|
}
|
|
|
|
if model.canShowMoreBackgroundApps {
|
|
Button {
|
|
model.showMoreBackgroundApps()
|
|
} label: {
|
|
Label(
|
|
"Show \(model.hiddenBackgroundCount) more",
|
|
systemImage: "ellipsis.circle"
|
|
)
|
|
.font(.caption)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.foregroundStyle(.secondary)
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 4)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var footer: some View {
|
|
HStack(spacing: 8) {
|
|
Text("\(model.regularApps.count) apps · \(model.backgroundApps.count) background")
|
|
.font(.caption2)
|
|
.foregroundStyle(.tertiary)
|
|
Spacer()
|
|
Button(LoginItem.isEnabled ? "Disable Launch at Login" : "Launch at Login") {
|
|
if !LoginItem.toggle() {
|
|
let alert = NSAlert()
|
|
alert.messageText = "Could not update Launch at Login"
|
|
alert.informativeText = "Launch at Login requires a signed app bundle in /Applications. It is not available when running via `swift run`."
|
|
alert.alertStyle = .warning
|
|
alert.addButton(withTitle: "OK")
|
|
alert.runModal()
|
|
}
|
|
}
|
|
.font(.caption2)
|
|
.buttonStyle(.plain)
|
|
|
|
Button("Quit") { NSApp.terminate(nil) }
|
|
.font(.caption2)
|
|
.buttonStyle(.plain)
|
|
.keyboardShortcut("q")
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 6)
|
|
}
|
|
}
|
|
|
|
private struct MenuBarTile: View {
|
|
let item: MenuBarItem
|
|
@ObservedObject var menuBar: MenuBarItemManager
|
|
|
|
var body: some View {
|
|
Button {
|
|
menuBar.press(item)
|
|
} label: {
|
|
// Preserve the symbol's aspect ratio: forcing wide symbols like the
|
|
// battery into a square stretches them into a blocky shape.
|
|
Image(nsImage: item.icon)
|
|
.resizable()
|
|
.scaledToFit()
|
|
.frame(width: 20, height: 20)
|
|
.padding(6)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 6)
|
|
.fill(Color.secondary.opacity(0.12))
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help("\(item.appName) — \(item.title)")
|
|
}
|
|
}
|
|
|
|
private struct AppRow: View {
|
|
let app: RunningApp
|
|
@ObservedObject var model: AppListModel
|
|
|
|
var body: some View {
|
|
Button {
|
|
model.activate(app)
|
|
} label: {
|
|
HStack(spacing: 8) {
|
|
Image(nsImage: app.icon)
|
|
.resizable()
|
|
.frame(width: 18, height: 18)
|
|
Text(app.name)
|
|
.font(.system(size: 12))
|
|
.lineLimit(1)
|
|
.truncationMode(.middle)
|
|
if app.isActive {
|
|
Image(systemName: "circle.fill")
|
|
.font(.system(size: 5))
|
|
.foregroundStyle(Color.accentColor)
|
|
}
|
|
Spacer(minLength: 0)
|
|
}
|
|
.contentShape(Rectangle())
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 3)
|
|
.opacity(app.isHidden ? 0.55 : 1)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help("Activate \(app.name)")
|
|
.contextMenu {
|
|
Button("Activate") { model.activate(app) }
|
|
Button(app.isHidden ? "Unhide" : "Hide") { model.toggleHidden(app) }
|
|
Divider()
|
|
Button("Quit \(app.name)") { model.quit(app) }
|
|
}
|
|
}
|
|
}
|