Hiding other apps' icons worked, but Apple pins Spotlight, Clock, Battery
and Control Center to the right of the menu bar, so a third-party item can
never be placed after them to push them away. Document the real limit so
the behaviour is not mistaken for a bug.
💘 Generated with Crush
Assisted-by: Crush:deepseek-v4.1-flash
300 lines
10 KiB
Swift
300 lines
10 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) {
|
|
HStack(spacing: 6) {
|
|
Text("MENU BAR")
|
|
.font(.caption2)
|
|
.fontWeight(.semibold)
|
|
.foregroundStyle(.secondary)
|
|
Spacer()
|
|
Text("\(menuBar.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 !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 {
|
|
LazyVGrid(columns: [GridItem(.adaptive(minimum: 34), spacing: 6)], spacing: 6) {
|
|
ForEach(menuBar.items) { item in
|
|
MenuBarTile(item: item, menuBar: menuBar)
|
|
}
|
|
}
|
|
.padding(.horizontal, 12)
|
|
}
|
|
|
|
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 Spotlight, Clock, Battery and Control Center stay pinned on the right and cannot be moved by any third-party app.")
|
|
}
|
|
}
|
|
}
|
|
|
|
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: {
|
|
Image(nsImage: item.appIcon)
|
|
.resizable()
|
|
.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) }
|
|
}
|
|
}
|
|
}
|