Add SimpleTopBar menu bar app with collapsible background app list
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
/// The top bar panel shown from the menu bar icon.
|
||||
struct TopBarView: View {
|
||||
@ObservedObject var model: AppListModel
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
Divider()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
section(title: "Apps", apps: model.regularApps, emptyText: "No apps running")
|
||||
|
||||
Divider().padding(.vertical, 4)
|
||||
|
||||
backgroundSection
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
.frame(maxHeight: 360)
|
||||
|
||||
Divider()
|
||||
footer
|
||||
}
|
||||
.frame(width: 320)
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "menubar.rectangle")
|
||||
.foregroundStyle(.secondary)
|
||||
Text("SimpleTopBar")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Button {
|
||||
model.refresh()
|
||||
} label: {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("Refresh")
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
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 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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user