diff --git a/.changeset/b4ebf24f.md b/.changeset/b4ebf24f.md new file mode 100644 index 0000000..922ba02 --- /dev/null +++ b/.changeset/b4ebf24f.md @@ -0,0 +1,5 @@ +--- +bump: patch +--- + +Add a display-mode setting: dock, menu bar, or hidden. diff --git a/Sources/cmdcmd/Config.swift b/Sources/cmdcmd/Config.swift index 8351675..f43d1cd 100644 --- a/Sources/cmdcmd/Config.swift +++ b/Sources/cmdcmd/Config.swift @@ -1,15 +1,23 @@ import Foundation +enum DisplayMode: String, Codable, CaseIterable { + case dock + case menuBar = "menu-bar" + case hidden +} + struct Config: Codable { var animations: Bool var trigger: String? var bindings: [String: Action] var livePreviews: Bool? + var displayMode: DisplayMode? var triggerSpec: String { trigger ?? "cmd-cmd" } var livePreviewsEnabled: Bool { livePreviews ?? true } + var displayModeOrDefault: DisplayMode { displayMode ?? .dock } - static let `default` = Config(animations: true, trigger: nil, bindings: [:], livePreviews: nil) + static let `default` = Config(animations: true, trigger: nil, bindings: [:], livePreviews: nil, displayMode: nil) static var fileURL: URL { URL(fileURLWithPath: NSHomeDirectory()) diff --git a/Sources/cmdcmd/SettingsWindow.swift b/Sources/cmdcmd/SettingsWindow.swift index a985207..a750b08 100644 --- a/Sources/cmdcmd/SettingsWindow.swift +++ b/Sources/cmdcmd/SettingsWindow.swift @@ -11,7 +11,7 @@ final class SettingsWindowController: NSWindowController { init(config: Config) { model = SettingsModel(config: config) let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 460, height: 320), + contentRect: NSRect(x: 0, y: 0, width: 460, height: 400), styleMask: [.titled, .closable, .miniaturizable], backing: .buffered, defer: false @@ -29,6 +29,7 @@ final class SettingsWindowController: NSWindowController { private final class SettingsModel: ObservableObject { @Published var animations: Bool { didSet { save() } } @Published var livePreviews: Bool { didSet { save() } } + @Published var displayMode: DisplayMode { didSet { save() } } private var base: Config @Published var status: String = "" var onSave: ((Config) -> Void)? @@ -36,6 +37,7 @@ private final class SettingsModel: ObservableObject { init(config: Config) { animations = config.animations livePreviews = config.livePreviewsEnabled + displayMode = config.displayModeOrDefault base = config } @@ -43,10 +45,12 @@ private final class SettingsModel: ObservableObject { var config = base config.animations = animations config.livePreviews = livePreviews + config.displayMode = displayMode do { try Config.patchOnDisk([ ("animations", animations ? "true" : "false"), ("livePreviews", livePreviews ? "true" : "false"), + ("displayMode", "\"\(displayMode.rawValue)\""), ]) base = config onSave?(config) @@ -96,6 +100,20 @@ private struct SettingsRootView: View { } .toggleStyle(.switch) + VStack(alignment: .leading, spacing: 6) { + Text("Show app in").font(.system(size: 13, weight: .medium)) + Picker("", selection: $model.displayMode) { + Text("Dock").tag(DisplayMode.dock) + Text("Menu Bar").tag(DisplayMode.menuBar) + Text("Hidden").tag(DisplayMode.hidden) + } + .labelsHidden() + .pickerStyle(.segmented) + Text("Hidden mode keeps the app running with no Dock or menu bar UI. Re-launch cmdcmd.app to bring Settings back.") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer(minLength: 0) HStack(spacing: 10) { @@ -108,6 +126,6 @@ private struct SettingsRootView: View { } } .padding(24) - .frame(minWidth: 420, minHeight: 280) + .frame(minWidth: 420, minHeight: 360) } } diff --git a/Sources/cmdcmd/main.swift b/Sources/cmdcmd/main.swift index 414c428..7c37e06 100644 --- a/Sources/cmdcmd/main.swift +++ b/Sources/cmdcmd/main.swift @@ -16,7 +16,6 @@ if let i = args.firstIndex(of: "--render-iconset"), i + 1 < args.count { } let app = NSApplication.shared -app.setActivationPolicy(.regular) app.applicationIconImage = AppIcon.makePlaceholder() final class AppDelegate: NSObject, NSApplicationDelegate { @@ -28,8 +27,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate { var settingsFactory: (() -> SettingsWindowController)? private var settingsController: SettingsWindowController? + private var statusItem: NSStatusItem? func applicationDockMenu(_ sender: NSApplication) -> NSMenu? { + return buildAppMenu() + } + + private func buildAppMenu() -> NSMenu { let menu = NSMenu() let settingsItem = NSMenuItem(title: "Settings…", action: #selector(openSettings), keyEquivalent: "") settingsItem.target = self @@ -42,9 +46,43 @@ final class AppDelegate: NSObject, NSApplicationDelegate { keyEquivalent: "") checkItem.target = updaterController menu.addItem(checkItem) + menu.addItem(.separator()) + menu.addItem(NSMenuItem(title: "Quit cmdcmd", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")) return menu } + func applyDisplayMode(_ mode: DisplayMode) { + switch mode { + case .dock: + removeStatusItem() + NSApp.setActivationPolicy(.regular) + case .menuBar: + NSApp.setActivationPolicy(.accessory) + installStatusItem() + case .hidden: + removeStatusItem() + NSApp.setActivationPolicy(.accessory) + } + } + + private func installStatusItem() { + if statusItem == nil { + let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) + let icon = NSImage(systemSymbolName: "command", accessibilityDescription: "cmdcmd") + icon?.isTemplate = true + item.button?.image = icon + item.menu = buildAppMenu() + statusItem = item + } + } + + private func removeStatusItem() { + if let s = statusItem { + NSStatusBar.system.removeStatusItem(s) + } + statusItem = nil + } + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { if !flag { openSettings() } return true @@ -73,6 +111,7 @@ app.finishLaunching() _ = try? Config.ensureExists() var appConfig = Config.load() +appDelegate.applyDisplayMode(appConfig.displayModeOrDefault) let tracker = SpaceTracker() let overlay = Overlay(tracker: tracker, config: appConfig) var trigger: AnyObject? @@ -82,6 +121,7 @@ appDelegate.settingsFactory = { controller.onSave = { newConfig in appConfig = newConfig overlay.updateConfig(newConfig) + appDelegate.applyDisplayMode(newConfig.displayModeOrDefault) } return controller }