diff --git a/.pi/semantic-grep.sqlite b/.pi/semantic-grep.sqlite new file mode 100644 index 0000000..fe8e60c Binary files /dev/null and b/.pi/semantic-grep.sqlite differ diff --git a/Package.swift b/Package.swift index efc14f7..4f9de23 100644 --- a/Package.swift +++ b/Package.swift @@ -7,6 +7,9 @@ let package = Package( targets: [ .executableTarget( name: "cmdcmd", + swiftSettings: [ + .unsafeFlags(["-parse-as-library"]), + ], linkerSettings: [ .linkedFramework("AppKit"), .linkedFramework("ScreenCaptureKit"), diff --git a/README.md b/README.md index 5b7e9b4..2bc3b18 100644 --- a/README.md +++ b/README.md @@ -33,11 +33,13 @@ Tile order and ignored windows persist per display via `UserDefaults`. Idle wind ### Config file -Right-click the `⌘ ⌘` Dock icon and pick **Open Config…** — that creates `~/Library/Application Support/cmdcmd/config.json` (if missing) and opens it in your default editor. Loaded at app launch; restart after edits. +Right-click the `⌘ ⌘` Dock icon and pick **Settings…** for the built-in settings window, or **Open Config…** to edit `~/Library/Application Support/cmdcmd/config.json` directly. Trigger edits require a restart; visual settings apply immediately from the settings window. ```json { "animations": true, + "minimalMode": true, + "displayMode": "dock", "trigger": "cmd-cmd", "bindings": { "h": "move-left", @@ -51,6 +53,10 @@ Right-click the `⌘ ⌘` Dock icon and pick **Open Config…** — that creates `animations: false` skips the show / pick zoom transitions. +`minimalMode: true` is the default. It replaces live window previews with large app icons for a quieter, lighter overlay and avoids asking for Screen Recording permission. + +`displayMode` controls where cmdcmd appears: `"dock"`, `"menu-bar"`, or `"hidden"`. Hidden mode removes both Dock and menu bar UI; open the app again from Launchpad/Spotlight/Finder to bring settings back. + `trigger` chooses what summons the overlay. Default `"cmd-cmd"` is the both-Command-keys chord. Anything else is treated as a regular hotkey spec — e.g. `"cmd+shift+space"` or `"f13"` (uses the same shortcut grammar as `bindings`). Hotkeys other than the chord require Accessibility permission to be globally observable. Binding spec — modifier tokens: `cmd`, `shift`, `opt` (or `option`/`alt`), `ctrl`. Special keys: `esc`, `space`, `return`, `delete`, `left`, `right`, `up`, `down`. Anything else is a single character. @@ -79,9 +85,9 @@ On first launch you'll see an onboarding window explaining what the app needs an - **Screen Recording** — for live tile previews (ScreenCaptureKit). - **Accessibility** — for the ⌘⌘ chord listener and to raise / forward keys to the chosen window. -Each row has a Grant button that opens the matching pane in System Settings. Click Continue once both are toggled on. Both are required; the app does nothing without them. +Minimal mode is the default and does not show the permission onboarding at launch. If you turn off minimal mode, cmdcmd asks for the permissions needed for live previews. Each row has a Grant button that opens the matching pane in System Settings. -The app shows in the Dock as `⌘ ⌘`. Right-click it for **Open Config…** (or quit it the normal way). +By default the app shows in the Dock as `⌘ ⌘`. Right-click it for **Settings…**, **Open Config…**, or quit it the normal way. You can switch it to menu-bar-only or fully hidden in Settings. ## Layout @@ -93,7 +99,8 @@ Sources/cmdcmd/ Overlay.swift # overlay window, tile grid, selection, animations OverlayView.swift # NSWindow + NSView event router for the overlay HintPill.swift # bottom-center mode-hint label - Config.swift # JSON config loader (animations, trigger, bindings) + Config.swift # JSON config loader (animations, minimal mode, display mode, trigger, bindings) + SettingsWindow.swift # built-in settings window Keymap.swift # default shortcuts + override resolver HotkeyMonitor.swift # global hotkey trigger (alternative to CmdChord) Tile.swift # per-window SCStream preview layer diff --git a/Sources/cmdcmd/CmdChord.swift b/Sources/cmdcmd/CmdChord.swift index ae5dafc..3e55410 100644 --- a/Sources/cmdcmd/CmdChord.swift +++ b/Sources/cmdcmd/CmdChord.swift @@ -9,6 +9,8 @@ final class CmdChord { private var rightDown = false private var contaminated = false private var fired = false + private var eventTap: CFMachPort? + private var eventTapRunLoopSource: CFRunLoopSource? private let handler: () -> Void init(handler: @escaping () -> Void) { @@ -29,23 +31,56 @@ final class CmdChord { return e } monitors = [global, local, globalKey, localKey].compactMap { $0 } + installEventTap() } deinit { for m in monitors { NSEvent.removeMonitor(m) } + if let eventTap { CGEvent.tapEnable(tap: eventTap, enable: false) } + if let eventTapRunLoopSource { CFRunLoopRemoveSource(CFRunLoopGetMain(), eventTapRunLoopSource, .commonModes) } } private func markContaminated() { if leftDown || rightDown { contaminated = true } } + private func installEventTap() { + let mask = (1 << CGEventType.flagsChanged.rawValue) | (1 << CGEventType.keyDown.rawValue) + let callback: CGEventTapCallBack = { _, type, event, userInfo in + guard let userInfo else { return Unmanaged.passUnretained(event) } + let chord = Unmanaged.fromOpaque(userInfo).takeUnretainedValue() + DispatchQueue.main.async { + if type == .keyDown { + chord.markContaminated() + } else if type == .flagsChanged { + chord.handleFlags(keyCode: Int(event.getIntegerValueField(.keyboardEventKeycode)), flags: event.flags) + } + } + return Unmanaged.passUnretained(event) + } + let ref = Unmanaged.passUnretained(self).toOpaque() + guard let tap = CGEvent.tapCreate(tap: .cgSessionEventTap, place: .headInsertEventTap, options: .listenOnly, eventsOfInterest: CGEventMask(mask), callback: callback, userInfo: ref) else { + Log.write("cmd-cmd event tap unavailable") + return + } + eventTap = tap + let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) + eventTapRunLoopSource = source + CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes) + CGEvent.tapEnable(tap: tap, enable: true) + } + private func handleFlags(_ event: NSEvent) { - let cmd = event.modifierFlags.contains(.command) - switch Int(event.keyCode) { + handleFlags(keyCode: Int(event.keyCode), flags: event.cgEvent?.flags ?? CGEventFlags(rawValue: UInt64(event.modifierFlags.rawValue))) + } + + private func handleFlags(keyCode: Int, flags: CGEventFlags) { + let raw = flags.rawValue + switch keyCode { case kVK_Command: - leftDown = cmd && event.modifierFlags.rawValue & 0x8 != 0 + leftDown = raw & CGEventFlags.maskCommand.rawValue != 0 && raw & 0x8 != 0 case kVK_RightCommand: - rightDown = cmd && event.modifierFlags.rawValue & 0x10 != 0 + rightDown = raw & CGEventFlags.maskCommand.rawValue != 0 && raw & 0x10 != 0 default: return } diff --git a/Sources/cmdcmd/Config.swift b/Sources/cmdcmd/Config.swift index 2ceafcd..99cf67e 100644 --- a/Sources/cmdcmd/Config.swift +++ b/Sources/cmdcmd/Config.swift @@ -1,13 +1,48 @@ import Foundation +enum DisplayMode: String, Codable, CaseIterable { + case dock + case menuBar = "menu-bar" + case hidden +} + struct Config: Codable { var animations: Bool + var minimalMode: Bool + var displayMode: DisplayMode var trigger: String? var bindings: [String: Action] + var vimBindings: Bool + var letterJump: Bool var triggerSpec: String { trigger ?? "cmd-cmd" } - static let `default` = Config(animations: true, trigger: nil, bindings: [:]) + enum CodingKeys: String, CodingKey { + case animations, minimalMode, displayMode, trigger, bindings, vimBindings, letterJump + } + + init(animations: Bool, minimalMode: Bool, displayMode: DisplayMode, trigger: String?, bindings: [String: Action], vimBindings: Bool, letterJump: Bool) { + self.animations = animations + self.minimalMode = minimalMode + self.displayMode = displayMode + self.trigger = trigger + self.bindings = bindings + self.vimBindings = vimBindings + self.letterJump = letterJump + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + animations = try c.decodeIfPresent(Bool.self, forKey: .animations) ?? true + minimalMode = try c.decodeIfPresent(Bool.self, forKey: .minimalMode) ?? true + displayMode = try c.decodeIfPresent(DisplayMode.self, forKey: .displayMode) ?? .dock + trigger = try c.decodeIfPresent(String.self, forKey: .trigger) + bindings = try c.decodeIfPresent([String: Action].self, forKey: .bindings) ?? [:] + vimBindings = try c.decodeIfPresent(Bool.self, forKey: .vimBindings) ?? true + letterJump = try c.decodeIfPresent(Bool.self, forKey: .letterJump) ?? true + } + + static let `default` = Config(animations: true, minimalMode: true, displayMode: .dock, trigger: nil, bindings: [:], vimBindings: true, letterJump: true) static var fileURL: URL { URL(fileURLWithPath: NSHomeDirectory()) @@ -17,9 +52,13 @@ struct Config: Codable { static let template = """ { "animations": true, + "minimalMode": true, + "displayMode": "dock", "trigger": "cmd-cmd", "bindings": { - } + }, + "vimBindings": true, + "letterJump": true } """ @@ -33,6 +72,15 @@ struct Config: Codable { } } + static func save(_ config: Config) throws { + let dir = fileURL.deletingLastPathComponent() + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(config) + try data.write(to: fileURL, options: .atomic) + } + static func ensureExists() throws -> URL { let dir = fileURL.deletingLastPathComponent() try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) diff --git a/Sources/cmdcmd/Keymap.swift b/Sources/cmdcmd/Keymap.swift index 1f51c98..9aded20 100644 --- a/Sources/cmdcmd/Keymap.swift +++ b/Sources/cmdcmd/Keymap.swift @@ -78,10 +78,15 @@ struct Shortcut: Hashable { final class Keymap { private var bindings: [Shortcut: Action] = [:] - init(overrides: [String: Action] = [:]) { + init(overrides: [String: Action] = [:], vimBindings: Bool = true) { for (raw, action) in Self.defaults { if let s = Shortcut.parse(raw) { bindings[s] = action } } + if vimBindings { + for (raw, action) in Self.vimDefaults { + if let s = Shortcut.parse(raw) { bindings[s] = action } + } + } for (raw, action) in overrides { if let s = Shortcut.parse(raw) { bindings[s] = action } } @@ -106,4 +111,8 @@ final class Keymap { "1": .pick1, "2": .pick2, "3": .pick3, "4": .pick4, "5": .pick5, "6": .pick6, "7": .pick7, "8": .pick8, "9": .pick9, ] + + static let vimDefaults: [String: Action] = [ + "h": .moveLeft, "l": .moveRight, "k": .moveUp, "j": .moveDown, + ] } diff --git a/Sources/cmdcmd/NSWindowAnimations.swift b/Sources/cmdcmd/NSWindowAnimations.swift new file mode 100644 index 0000000..4fa0e69 --- /dev/null +++ b/Sources/cmdcmd/NSWindowAnimations.swift @@ -0,0 +1,33 @@ +import AppKit + +extension NSWindow { + func fadeInAndUp(distance: CGFloat = 50, duration: TimeInterval = 0.125, callback: (() -> Void)? = nil) { + let toFrame = frame + let fromFrame = NSRect(x: toFrame.minX, y: toFrame.minY - distance, width: toFrame.width, height: toFrame.height) + setFrame(fromFrame, display: true) + alphaValue = 0 + NSAnimationContext.runAnimationGroup { context in + context.duration = duration + context.timingFunction = CAMediaTimingFunction(controlPoints: 0.4, 0, 0.2, 1) + animator().alphaValue = 1 + animator().setFrame(toFrame, display: true) + } completionHandler: { + callback?() + } + } + + func fadeOutAndDown(distance: CGFloat = 50, duration: TimeInterval = 0.125, callback: (() -> Void)? = nil) { + let fromFrame = frame + let toFrame = NSRect(x: fromFrame.minX, y: fromFrame.minY - distance, width: fromFrame.width, height: fromFrame.height) + setFrame(fromFrame, display: true) + alphaValue = 1 + NSAnimationContext.runAnimationGroup { context in + context.duration = duration + context.timingFunction = CAMediaTimingFunction(controlPoints: 0.4, 0, 0.2, 1) + animator().alphaValue = 0 + animator().setFrame(toFrame, display: true) + } completionHandler: { + callback?() + } + } +} diff --git a/Sources/cmdcmd/Overlay.swift b/Sources/cmdcmd/Overlay.swift index 2ddbaab..bab29ce 100644 --- a/Sources/cmdcmd/Overlay.swift +++ b/Sources/cmdcmd/Overlay.swift @@ -18,8 +18,9 @@ final class Overlay { private var prevFrontTitle: String = "" private var showIgnored: Bool = false private var dragState: DragState? + private var lastLetterJump: String? private let tracker: SpaceTracker - private let config: Config + private var config: Config private var displayKey: String = "main" private var activeScreen: NSScreen? @@ -48,8 +49,16 @@ final class Overlay { } } + private static var usageOrder: [String] { + get { (UserDefaults.standard.array(forKey: "appUsageOrder") as? [String]) ?? [] } + set { UserDefaults.standard.set(Array(newValue.prefix(128)), forKey: "appUsageOrder") } + } + private var workspaceObserver: NSObjectProtocol? + private var appActivationObserver: NSObjectProtocol? private var activityTimer: Timer? + private var keyEventTap: CFMachPort? + private var keyEventTapRunLoopSource: CFRunLoopSource? private let hint = HintPill() init(tracker: SpaceTracker, config: Config) { @@ -63,6 +72,14 @@ final class Overlay { guard let self, self.visible, !self.isPicking else { return } self.hide(activatePrevious: false) } + appActivationObserver = NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.didActivateApplicationNotification, + object: nil, + queue: .main + ) { notification in + guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else { return } + Self.recordUse(of: app) + } } private var isPicking = false @@ -71,6 +88,14 @@ final class Overlay { if let o = workspaceObserver { NotificationCenter.default.removeObserver(o) } + if let o = appActivationObserver { + NSWorkspace.shared.notificationCenter.removeObserver(o) + } + } + + func updateConfig(_ config: Config) { + self.config = config + view?.keymap = Keymap(overrides: config.bindings, vimBindings: config.vimBindings) } func toggle() { @@ -97,13 +122,16 @@ final class Overlay { } private func show() { - prevFrontPID = NSWorkspace.shared.frontmostApplication?.processIdentifier ?? 0 + let prevApp = NSWorkspace.shared.frontmostApplication + prevFrontPID = prevApp?.processIdentifier ?? 0 + if let prevApp { Self.recordUse(of: prevApp) } prevFrontTitle = focusedWindowTitle(pid: prevFrontPID) ?? "" let screen = Self.cursorScreen() activeScreen = screen displayKey = Self.displayKeyString(for: screen) visible = true startActivityTimer() + startKeyEventTap() Task { await prepareAndShow() } } @@ -135,6 +163,44 @@ final class Overlay { } } + private func startKeyEventTap() { + stopKeyEventTap() + let mask = (1 << CGEventType.keyDown.rawValue) | (1 << CGEventType.keyUp.rawValue) + let callback: CGEventTapCallBack = { _, type, event, userInfo in + guard let userInfo else { return Unmanaged.passUnretained(event) } + let overlay = Unmanaged.fromOpaque(userInfo).takeUnretainedValue() + guard overlay.visible, let nsEvent = NSEvent(cgEvent: event) else { return Unmanaged.passUnretained(event) } + return overlay.handleTappedKeyEvent(nsEvent, type: type) ? nil : Unmanaged.passUnretained(event) + } + let ref = Unmanaged.passUnretained(self).toOpaque() + guard let tap = CGEvent.tapCreate(tap: .cgSessionEventTap, place: .headInsertEventTap, options: .defaultTap, eventsOfInterest: CGEventMask(mask), callback: callback, userInfo: ref) else { + Log.write("overlay key event tap unavailable") + return + } + keyEventTap = tap + let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) + keyEventTapRunLoopSource = source + CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes) + CGEvent.tapEnable(tap: tap, enable: true) + } + + private func stopKeyEventTap() { + if let keyEventTap { CGEvent.tapEnable(tap: keyEventTap, enable: false) } + if let keyEventTapRunLoopSource { CFRunLoopRemoveSource(CFRunLoopGetMain(), keyEventTapRunLoopSource, .commonModes) } + keyEventTap = nil + keyEventTapRunLoopSource = nil + } + + private func handleTappedKeyEvent(_ event: NSEvent, type: CGEventType) -> Bool { + let run = { + if type == .keyDown { return self.view?.handleKeyDown(event) == true } + if type == .keyUp { return self.view?.handleKeyUp(event) == true } + return false + } + if Thread.isMainThread { return run() } + return DispatchQueue.main.sync(execute: run) + } + private func stopActivityTimer() { activityTimer?.invalidate() activityTimer = nil @@ -153,6 +219,35 @@ final class Overlay { } private func prepareAndShow() async { + let frames: (display: CGRect, visible: CGRect) = await MainActor.run { + let s = self.activeScreen ?? Self.cursorScreen() + return (CGDisplayBounds(Self.displayID(for: s)), s.visibleFrame) + } + if config.minimalMode { + let candidates = tracker.windows().filter(Self.isCapturableMinimal).filter { Self.windowMostlyOn(displayBounds: frames.display, window: $0) } + await MainActor.run { + guard visible else { return } + let panelFrame = minimalPanelFrame(tileCount: candidates.count, visibleFrame: frames.visible) + let w = window ?? makeWindow(frame: panelFrame) + window = w + w.setFrame(panelFrame, display: false) + w.alphaValue = 1 + NSApp.activate(ignoringOtherApps: true) + w.makeKeyAndOrderFront(nil) + if let v = view { + w.makeFirstResponder(v) + DispatchQueue.main.async { w.makeFirstResponder(v) } + } + CATransaction.begin() + CATransaction.setDisableActions(true) + installMinimalTiles(candidates: candidates) + CATransaction.commit() + if config.animations { + w.fadeInAndUp(distance: 28, duration: 0.125) + } + } + return + } let scContent: SCShareableContent? do { scContent = try await SCShareableContent.excludingDesktopWindows(true, onScreenWindowsOnly: true) @@ -161,10 +256,6 @@ final class Overlay { scContent = nil } let allCandidates = (scContent?.windows ?? []).filter(Self.isCapturable) - let frames: (display: CGRect, visible: CGRect) = await MainActor.run { - let s = self.activeScreen ?? Self.cursorScreen() - return (CGDisplayBounds(Self.displayID(for: s)), s.visibleFrame) - } let candidates = allCandidates.filter { Self.windowMostlyOn(displayBounds: frames.display, window: $0) } await MainActor.run { @@ -173,9 +264,12 @@ final class Overlay { window = w w.setFrame(frames.visible, display: false) w.alphaValue = 1 - w.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) - if let v = view { w.makeFirstResponder(v) } + w.makeKeyAndOrderFront(nil) + if let v = view { + w.makeFirstResponder(v) + DispatchQueue.main.async { w.makeFirstResponder(v) } + } CATransaction.begin() CATransaction.setDisableActions(true) installTiles(candidates: candidates) @@ -188,9 +282,16 @@ final class Overlay { private static let pickDuration: Double = 0.16 private func animateShowFromFocused(in w: NSWindow) { - guard tiles.indices.contains(selectedIndex), - let bounds = w.contentView?.bounds, bounds.width > 0 else { return } - guard config.animations else { return } + guard tiles.indices.contains(selectedIndex), config.animations else { return } + if config.minimalMode { + CATransaction.begin() + CATransaction.setAnimationDuration(0.12) + CATransaction.setAnimationTimingFunction(Self.smoothEasing) + for t in tiles { t.layer.opacity = 1 } + CATransaction.commit() + return + } + guard let bounds = w.contentView?.bounds, bounds.width > 0 else { return } let tile = tiles[selectedIndex] let gridFrame = tile.layer.frame @@ -222,32 +323,63 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B return total > 0 && interArea / total >= 0.5 } + private static func windowMostlyOn(displayBounds: CGRect, window: SpaceWindow) -> Bool { + let inter = window.bounds.intersection(displayBounds) + guard !inter.isNull else { return false } + let interArea = inter.width * inter.height + let total = window.bounds.width * window.bounds.height + return total > 0 && interArea / total >= 0.5 + } + + private func minimalPanelFrame(tileCount: Int, visibleFrame: CGRect) -> CGRect { + let count = max(1, tileCount) + let target: CGFloat = 64 + let gap: CGFloat = 12 + let padX: CGFloat = 22 + let padY: CGFloat = 18 + let maxCols = max(1, Int((visibleFrame.width * 0.72 - padX * 2 + gap) / (target + gap))) + let cols = min(count, maxCols) + let rows = Int(ceil(Double(count) / Double(cols))) + let width = CGFloat(cols) * target + CGFloat(max(0, cols - 1)) * gap + padX * 2 + let height = CGFloat(rows) * target + CGFloat(max(0, rows - 1)) * gap + padY * 2 + return CGRect(x: visibleFrame.midX - width / 2, y: visibleFrame.midY - height / 2 + 42, width: width, height: height) + } + + private func installMinimalTiles(candidates: [SpaceWindow]) { + installOrderedTiles(candidates.map { Tile(spaceWindow: $0) }) + } + private func installTiles(candidates: [SCWindow]) { let mcTiles: [Tile] = candidates.compactMap { w -> Tile? in guard let pid = w.owningApplication?.processID else { return nil } - return Tile(scWindow: w, ownerPID: pid) + return Tile(scWindow: w, ownerPID: pid, minimalMode: config.minimalMode) } + installOrderedTiles(mcTiles) + } + + private func installOrderedTiles(_ mcTiles: [Tile]) { let saved = savedOrder - let ordered: [Tile] - if saved.isEmpty { - ordered = mcTiles - } else { - let presentIDs = Set(mcTiles.map { CGWindowID($0.scWindow.windowID) }) - let knownInOrder = saved.filter { presentIDs.contains($0) } - let knownIDs = Set(knownInOrder) - let known = knownInOrder.compactMap { wid in mcTiles.first(where: { CGWindowID($0.scWindow.windowID) == wid }) } - let unknown = mcTiles.filter { !knownIDs.contains(CGWindowID($0.scWindow.windowID)) } - ordered = known + unknown - } - savedOrder = ordered.map { CGWindowID($0.scWindow.windowID) } + let usage = Self.usageOrder + let savedRanks = Dictionary(uniqueKeysWithValues: saved.enumerated().map { ($0.element, $0.offset) }) + let usageRanks = Dictionary(uniqueKeysWithValues: usage.enumerated().map { ($0.element, $0.offset) }) + let ordered = mcTiles.sorted { a, b in + let ar = usageRanks[Self.usageKey(for: a)] ?? Int.max + let br = usageRanks[Self.usageKey(for: b)] ?? Int.max + if ar != br { return ar < br } + let asr = savedRanks[a.windowID] ?? Int.max + let bsr = savedRanks[b.windowID] ?? Int.max + if asr != bsr { return asr < bsr } + return a.windowID < b.windowID + } + savedOrder = ordered.map { $0.windowID } allTiles = ordered for t in ordered { - window?.contentView?.layer?.addSublayer(t.layer) + view?.layer?.addSublayer(t.layer) } rebuildDisplayed() - if let i = tiles.firstIndex(where: { $0.ownerPID == prevFrontPID && ($0.scWindow.title ?? "") == prevFrontTitle }) + if let i = tiles.firstIndex(where: { $0.ownerPID == prevFrontPID && ($0.sourceTitle ?? "") == prevFrontTitle }) ?? tiles.firstIndex(where: { $0.ownerPID == prevFrontPID }) { selectedIndex = i updateSelection() @@ -267,7 +399,7 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B t.layer.isHidden = showIgnored ? false : isIgnored t.layer.opacity = (showIgnored && isIgnored) ? 0.3 : 1.0 t.setNumber(nil) - t.tintColorName = paneColors[CGWindowID(t.scWindow.windowID)] + t.tintColorName = paneColors[t.windowID] } tiles = displayed for (i, t) in tiles.enumerated() { @@ -283,7 +415,7 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B private func tagSelectedColor(_ name: String?) { guard tiles.indices.contains(selectedIndex) else { return } - let id = CGWindowID(tiles[selectedIndex].scWindow.windowID) + let id = tiles[selectedIndex].windowID if let name { paneColors[id] = name } else { paneColors.removeValue(forKey: id) } tiles[selectedIndex].tintColorName = name } @@ -343,11 +475,24 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B } } + private func selectApp(startingWith letter: String) { + guard config.letterJump, !tiles.isEmpty else { return } + let needle = letter.lowercased() + let start = lastLetterJump == needle ? selectedIndex + 1 : 0 + let orderedIndices = Array(start.. B removed.layer.removeFromSuperlayer() Task { await removed.stop() } - savedOrder = allTiles.map { CGWindowID($0.scWindow.windowID) } + savedOrder = allTiles.map { $0.windowID } if !tiles.indices.contains(selectedIndex) { selectedIndex = max(0, tiles.count - 1) } @@ -405,6 +550,7 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B private func hide(activatePrevious: Bool = true) { let toStop = allTiles stopActivityTimer() + stopKeyEventTap() window?.orderOut(nil) visible = false if activatePrevious, prevFrontPID != 0, @@ -417,15 +563,16 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B selectedIndex = 0 showIgnored = false view?.resetMomentaryPeek() + lastLetterJump = nil hint.hide() Task { - for t in toStop { await t.stop() } + for t in toStop { + t.layer.removeFromSuperlayer() + await t.stop() + } } isZoomed = false savedFrames = [] - if let root = window?.contentView?.layer { - root.sublayers?.forEach { $0.removeFromSuperlayer() } - } hint.reset() } @@ -438,12 +585,34 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B } private func layoutTiles(in bounds: NSRect) { + if config.minimalMode { + let target: CGFloat = 64 + let gap: CGFloat = 12 + let cols = min(max(1, tiles.count), max(1, Int((bounds.width - 44 + gap) / (target + gap)))) + let rows = Int(ceil(Double(tiles.count) / Double(cols))) + let totalWidth = CGFloat(cols) * target + CGFloat(max(0, cols - 1)) * gap + let totalHeight = CGFloat(rows) * target + CGFloat(max(0, rows - 1)) * gap + let startX = bounds.midX - totalWidth / 2 + let startY = bounds.midY - totalHeight / 2 + gridCols = cols + for (i, tile) in tiles.enumerated() { + let col = i % cols + let row = i / cols + tile.setFrame(CGRect( + x: startX + CGFloat(col) * (target + gap), + y: startY + CGFloat(rows - row - 1) * (target + gap), + width: target, + height: target + )) + } + return + } let screenSize = activeScreen?.frame.size ?? NSScreen.main?.frame.size ?? bounds.size let ar = screenSize.width / max(1, screenSize.height) let (rects, cols) = GridLayout.frames(count: tiles.count, bounds: bounds, aspectRatio: ar) gridCols = cols for (tile, cell) in zip(tiles, rects) { - let src = tile.scWindow.frame + let src = tile.sourceFrame let srcAR = src.width / max(1, src.height) let cellAR = cell.width / max(1, cell.height) let fitted: CGRect @@ -484,8 +653,8 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B guard tiles.indices.contains(selectedIndex), !isPicking else { return } let tile = tiles[selectedIndex] let pid = tile.ownerPID - let windowID = CGWindowID(tile.scWindow.windowID) - let title = tile.scWindow.title + let windowID = tile.windowID + let title = tile.sourceTitle prevFrontPID = 0 isPicking = true @@ -501,6 +670,21 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B return } + if config.minimalMode { + CATransaction.begin() + CATransaction.setAnimationDuration(0.08) + CATransaction.setAnimationTimingFunction(Self.smoothEasing) + w.alphaValue = 0 + CATransaction.commit() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) { [weak self] in + guard let self else { return } + w.alphaValue = 1 + self.hide(activatePrevious: false) + self.isPicking = false + } + return + } + CATransaction.begin() CATransaction.setDisableActions(true) tile.highlight = .none @@ -513,7 +697,7 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B CATransaction.setAnimationTimingFunction(Self.smoothEasing) tile.setFrame(bounds) CATransaction.commit() - _ = w + _ = bounds DispatchQueue.main.asyncAfter(deadline: .now() + Self.pickDuration) { [weak self] in guard let self else { return } @@ -604,7 +788,7 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B if state.moved { if let target = tiles.firstIndex(where: { $0 !== tile && $0.layer.frame.contains(point) }) { tiles.swapAt(state.index, target) - savedOrder = tiles.map { CGWindowID($0.scWindow.windowID) } + savedOrder = tiles.map { $0.windowID } selectedIndex = target renumberTiles() } @@ -627,7 +811,7 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B let target = newRow * cols + newCol guard target >= 0, target < tiles.count, target != selectedIndex else { return } tiles.swapAt(selectedIndex, target) - savedOrder = tiles.map { CGWindowID($0.scWindow.windowID) } + savedOrder = tiles.map { $0.windowID } selectedIndex = target renumberTiles() layoutTilesAnimated() @@ -651,9 +835,10 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B private func beginZoom() { guard !isZoomed, tiles.indices.contains(selectedIndex) else { return } let bounds = window?.contentView?.bounds ?? .zero - let pad: CGFloat = 4 - let avail = bounds.insetBy(dx: pad, dy: pad) - let src = tiles[selectedIndex].scWindow.frame + let thumbHeight = min(max(bounds.height * 0.12, 64), 110) + let pad: CGFloat = 18 + let avail = bounds.insetBy(dx: pad, dy: pad).insetBy(dx: 0, dy: thumbHeight * 0.45) + let src = tiles[selectedIndex].sourceFrame let srcAR = src.width / max(1, src.height) let availAR = avail.width / max(1, avail.height) let target: CGRect @@ -667,14 +852,26 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B savedFrames = tiles.map { $0.layer.frame } isZoomed = true CATransaction.begin() - CATransaction.setAnimationDuration(0.12) - CATransaction.setAnimationTimingFunction(CAMediaTimingFunction(name: .easeInEaseOut)) + CATransaction.setAnimationDuration(0.16) + CATransaction.setAnimationTimingFunction(Self.smoothEasing) + let others = tiles.enumerated().filter { $0.offset != selectedIndex } + let thumbWidth = min(150, max(70, (bounds.width - pad * 2) / CGFloat(max(1, others.count)))) + let totalWidth = thumbWidth * CGFloat(others.count) + var x = bounds.midX - totalWidth / 2 for (i, t) in tiles.enumerated() { if i == selectedIndex { - t.layer.zPosition = 1 + t.layer.zPosition = 10 + t.layer.opacity = 1 t.setFrame(target) } else { - t.layer.opacity = 0 + t.layer.zPosition = 0 + t.layer.opacity = 0.38 + let original = savedFrames.indices.contains(i) ? savedFrames[i] : t.layer.frame + let ar = original.width / max(1, original.height) + let w = min(thumbWidth - 8, thumbHeight * ar) + let frame = CGRect(x: x + (thumbWidth - w) / 2, y: pad, width: w, height: thumbHeight) + t.setFrame(frame) + x += thumbWidth } } CATransaction.commit() @@ -684,8 +881,8 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B guard isZoomed else { return } isZoomed = false CATransaction.begin() - CATransaction.setAnimationDuration(0.12) - CATransaction.setAnimationTimingFunction(CAMediaTimingFunction(name: .easeInEaseOut)) + CATransaction.setAnimationDuration(0.16) + CATransaction.setAnimationTimingFunction(Self.smoothEasing) for (i, t) in tiles.enumerated() { if i < savedFrames.count { t.setFrame(savedFrames[i]) } t.layer.zPosition = 0 @@ -695,6 +892,27 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B savedFrames = [] } + private static func recordUse(of app: NSRunningApplication) { + guard app.processIdentifier != getpid(), app.activationPolicy == .regular else { return } + let key = usageKey(pid: app.processIdentifier, bundleIdentifier: app.bundleIdentifier) + var order = usageOrder.filter { $0 != key } + order.insert(key, at: 0) + usageOrder = order + } + + private static func usageKey(for tile: Tile) -> String { + usageKey(pid: tile.ownerPID, bundleIdentifier: tile.ownerBundleIdentifier) + } + + private static func usageKey(pid: pid_t, bundleIdentifier: String?) -> String { + if let bundleIdentifier, !bundleIdentifier.isEmpty { return bundleIdentifier } + return "pid:\(pid)" + } + + private static func isCommandTabApp(pid: pid_t) -> Bool { + NSRunningApplication(processIdentifier: pid)?.activationPolicy == .regular + } + private static let systemOwners: Set = [ "Window Server", "Dock", "WindowManager", "Control Center", "Spotlight", "NotificationCenter", "SystemUIServer", @@ -705,11 +923,20 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B guard let app = w.owningApplication else { return false } if app.processID == getpid() { return false } if systemOwners.contains(app.applicationName) { return false } + guard isCommandTabApp(pid: app.processID) else { return false } if w.frame.width < 200 || w.frame.height < 200 { return false } if !w.isOnScreen && w.windowLayer != 0 { return false } return true } + private static func isCapturableMinimal(_ w: SpaceWindow) -> Bool { + if w.ownerPID == getpid() { return false } + if systemOwners.contains(w.ownerName) { return false } + guard isCommandTabApp(pid: w.ownerPID) else { return false } + if w.bounds.width < 200 || w.bounds.height < 200 { return false } + return true + } + private func makeWindow(frame: NSRect) -> NSWindow { let w = OverlayWindow( contentRect: frame, @@ -717,22 +944,43 @@ private static func windowMostlyOn(displayBounds: CGRect, window: SCWindow) -> B backing: .buffered, defer: false ) - w.level = .floating + w.level = .screenSaver w.isOpaque = false - w.backgroundColor = NSColor.black.withAlphaComponent(0.85) + w.backgroundColor = .clear w.isOpaque = false w.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] - let v = OverlayView(frame: frame) + let container = NSView(frame: NSRect(origin: .zero, size: frame.size)) + container.autoresizingMask = [.width, .height] + container.wantsLayer = true + container.layer?.backgroundColor = config.minimalMode ? NSColor.clear.cgColor : NSColor.black.withAlphaComponent(0.18).cgColor + if config.minimalMode { + let blur = NSVisualEffectView(frame: container.bounds) + blur.autoresizingMask = [.width, .height] + blur.material = .hudWindow + blur.blendingMode = .withinWindow + blur.state = .active + blur.wantsLayer = true + blur.layer?.cornerRadius = 25 + blur.layer?.cornerCurve = .continuous + blur.layer?.masksToBounds = true + blur.layer?.borderWidth = 1 + blur.layer?.borderColor = NSColor.white.withAlphaComponent(0.18).cgColor + container.addSubview(blur) + } + let v = OverlayView(frame: container.bounds) + v.autoresizingMask = [.width, .height] v.wantsLayer = true v.layer?.backgroundColor = .clear - v.keymap = Keymap(overrides: config.bindings) + v.keymap = Keymap(overrides: config.bindings, vimBindings: config.vimBindings) v.onAction = { [weak self] action in self?.dispatch(action) } v.onSpaceDown = { [weak self] in self?.beginZoom() } v.onSpaceUp = { [weak self] in self?.endZoom() } v.onMouseDown = { [weak self] p in self?.mouseDownAt(p) } v.onMouseDragged = { [weak self] p in self?.mouseDraggedAt(p) } v.onMouseUp = { [weak self] p in self?.mouseUpAt(p) } - w.contentView = v + v.onLetter = { [weak self] letter in self?.selectApp(startingWith: letter) } + container.addSubview(v) + w.contentView = container view = v return w } diff --git a/Sources/cmdcmd/OverlayView.swift b/Sources/cmdcmd/OverlayView.swift index 5d9668d..f304c22 100644 --- a/Sources/cmdcmd/OverlayView.swift +++ b/Sources/cmdcmd/OverlayView.swift @@ -1,8 +1,17 @@ import AppKit -final class OverlayWindow: NSWindow { +final class OverlayWindow: NSPanel { override var canBecomeKey: Bool { true } override var canBecomeMain: Bool { true } + + override init(contentRect: NSRect, styleMask style: NSWindow.StyleMask, backing backingStoreType: NSWindow.BackingStoreType, defer flag: Bool) { + super.init(contentRect: contentRect, styleMask: style.union(.nonactivatingPanel), backing: backingStoreType, defer: flag) + isFloatingPanel = true + isReleasedWhenClosed = false + animationBehavior = .none + backgroundColor = .clear + isOpaque = false + } } final class OverlayView: NSView { @@ -13,32 +22,53 @@ final class OverlayView: NSView { var onMouseDown: ((NSPoint) -> Void)? var onMouseDragged: ((NSPoint) -> Void)? var onMouseUp: ((NSPoint) -> Void)? + var onLetter: ((String) -> Void)? private var momentaryPeek = false override var acceptsFirstResponder: Bool { true } - override func keyDown(with event: NSEvent) { + @discardableResult + func handleKeyDown(_ event: NSEvent) -> Bool { let bareMods = event.modifierFlags.intersection([.command, .shift, .option, .control]) if event.keyCode == 49 && bareMods.isEmpty { - if event.isARepeat { return } + if event.isARepeat { return true } momentaryPeek = true onSpaceDown?() - return + return true } if let action = keymap.action(for: event) { onAction?(action) - return + return true } + if bareMods.isEmpty, + let chars = event.charactersIgnoringModifiers?.lowercased(), + chars.count == 1, + let scalar = chars.unicodeScalars.first, + CharacterSet.lowercaseLetters.contains(scalar) { + onLetter?(chars) + return true + } + return false } - override func keyUp(with event: NSEvent) { + @discardableResult + func handleKeyUp(_ event: NSEvent) -> Bool { if event.keyCode == 49 { if momentaryPeek { momentaryPeek = false onSpaceUp?() } - return + return true } + return false + } + + override func keyDown(with event: NSEvent) { + _ = handleKeyDown(event) + } + + override func keyUp(with event: NSEvent) { + _ = handleKeyUp(event) } func resetMomentaryPeek() { diff --git a/Sources/cmdcmd/SettingsWindow.swift b/Sources/cmdcmd/SettingsWindow.swift new file mode 100644 index 0000000..3abbae4 --- /dev/null +++ b/Sources/cmdcmd/SettingsWindow.swift @@ -0,0 +1,273 @@ +import AppKit +import SwiftUI + +final class SettingsWindowController: NSWindowController { + private let model: SettingsModel + var onSave: ((Config) -> Void)? { + get { model.onSave } + set { model.onSave = newValue } + } + + init(config: Config) { + model = SettingsModel(config: config) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 520), + styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], + backing: .buffered, + defer: false + ) + window.title = "cmdcmd Settings" + window.titlebarAppearsTransparent = true + window.isMovableByWindowBackground = true + window.isReleasedWhenClosed = false + window.center() + window.contentView = NSHostingView(rootView: SettingsRootView(model: model)) + super.init(window: window) + } + + required init?(coder: NSCoder) { nil } +} + +private final class SettingsModel: ObservableObject { + @Published var animations: Bool { didSet { save() } } + @Published var minimalMode: Bool { didSet { save() } } + @Published var displayMode: DisplayMode { didSet { save() } } + @Published var vimBindings: Bool { didSet { save() } } + @Published var letterJump: Bool { didSet { save() } } + private let trigger: String? + @Published var status: String = "" + var onSave: ((Config) -> Void)? + + init(config: Config) { + animations = config.animations + minimalMode = config.minimalMode + displayMode = config.displayMode + vimBindings = config.vimBindings + letterJump = config.letterJump + trigger = config.trigger + } + + func save() { + var config = Config.default + config.animations = animations + config.minimalMode = minimalMode + config.displayMode = displayMode + config.trigger = trigger + config.vimBindings = vimBindings + config.letterJump = letterJump + do { + try Config.save(config) + onSave?(config) + status = "Saved" + } catch { + status = "Save failed: \(error.localizedDescription)" + Log.write("settings save failed: \(error)") + } + } + + func openConfig() { + do { + let url = try Config.ensureExists() + NSWorkspace.shared.open(url) + } catch { + status = "Open failed: \(error.localizedDescription)" + Log.write("openConfig failed: \(error)") + } + } +} + +private struct SettingsRootView: View { + @ObservedObject var model: SettingsModel + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + header + settingsCard + presenceCard + inputCard + footer + } + .padding(.horizontal, 28) + .padding(.top, 34) + .padding(.bottom, 28) + .frame(maxWidth: .infinity, alignment: .leading) + } + .background(.regularMaterial) + .frame(minWidth: 560, minHeight: 440) + } + + private var header: some View { + HStack(spacing: 14) { + Image(nsImage: AppIcon.makePlaceholder()) + .resizable() + .frame(width: 46, height: 46) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + VStack(alignment: .leading, spacing: 3) { + Text("cmdcmd") + .font(.system(size: 25, weight: .semibold)) + Text("Fast app switching with a small native HUD.") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + } + Spacer() + SettingsHeaderActionButton(title: "Open Config", helpText: "Open the JSON config file") { + model.openConfig() + } + } + } + + private var settingsCard: some View { + SettingsCard { + SettingsCardRow("Animations", subtitle: "Use smooth open, pick, and peek transitions.") { + Toggle("", isOn: $model.animations) + .labelsHidden() + .toggleStyle(.switch) + } + SettingsCardDivider() + SettingsCardRow("Minimal icon mode", subtitle: "Show the LeaderKey-style app icon HUD instead of live window previews.") { + Toggle("", isOn: $model.minimalMode) + .labelsHidden() + .toggleStyle(.switch) + } + } + } + + private var presenceCard: some View { + SettingsCard { + SettingsCardRow("Show app in", subtitle: "Hidden mode can be reopened from Launchpad, Spotlight, Finder, or by launching the app again.", controlWidth: 210) { + Picker("", selection: $model.displayMode) { + Text("Dock").tag(DisplayMode.dock) + Text("Menu Bar").tag(DisplayMode.menuBar) + Text("Hidden").tag(DisplayMode.hidden) + } + .labelsHidden() + .pickerStyle(.segmented) + } + } + } + + private var inputCard: some View { + SettingsCard { + SettingsCardRow("Vim navigation", subtitle: "Use h, j, k, and l to move between apps.") { + Toggle("", isOn: $model.vimBindings) + .labelsHidden() + .toggleStyle(.switch) + } + SettingsCardDivider() + SettingsCardRow("First-letter app jump", subtitle: "Press an app’s first letter to select it; repeat to cycle matches.") { + Toggle("", isOn: $model.letterJump) + .labelsHidden() + .toggleStyle(.switch) + } + } + } + + private var footer: some View { + HStack(spacing: 10) { + Button("Save") { model.save() } + .keyboardShortcut(.defaultAction) + Text(model.status) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + Spacer() + } + } +} + +private struct SettingsHeaderActionButton: View { + let title: String + let helpText: String + let action: () -> Void + + var body: some View { + Button(action: action) { + Text(title) + .font(.system(size: 11.5, weight: .medium)) + .foregroundStyle(.secondary) + .padding(.horizontal, 9) + .padding(.vertical, 4) + .background( + Capsule(style: .continuous) + .fill(Color(nsColor: .controlBackgroundColor).opacity(0.34)) + ) + .overlay( + Capsule(style: .continuous) + .stroke(Color(nsColor: .separatorColor).opacity(0.22), lineWidth: 1) + ) + } + .buttonStyle(.plain) + .controlSize(.small) + .help(helpText) + } +} + +private struct SettingsCard: View { + @ViewBuilder let content: Content + + var body: some View { + VStack(spacing: 0) { + content + } + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(Color(nsColor: .controlBackgroundColor).opacity(0.38)) + ) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(Color(nsColor: .separatorColor).opacity(0.22), lineWidth: 1) + ) + } +} + +private struct SettingsCardRow: View { + let title: String + let subtitle: String? + let controlWidth: CGFloat? + @ViewBuilder let trailing: Trailing + + init(_ title: String, subtitle: String? = nil, controlWidth: CGFloat? = nil, @ViewBuilder trailing: () -> Trailing) { + self.title = title + self.subtitle = subtitle + self.controlWidth = controlWidth + self.trailing = trailing() + } + + var body: some View { + HStack(alignment: .center, spacing: 12) { + VStack(alignment: .leading, spacing: subtitle == nil ? 0 : 3) { + Text(title) + .font(.system(size: 13, weight: .medium)) + if let subtitle { + Text(subtitle) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + + Group { + if let controlWidth { + trailing.frame(width: controlWidth, alignment: .trailing) + } else { + trailing + } + } + .layoutPriority(1) + } + .padding(.horizontal, 14) + .padding(.vertical, 9) + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +private struct SettingsCardDivider: View { + var body: some View { + Rectangle() + .fill(Color(nsColor: .separatorColor).opacity(0.22)) + .frame(height: 1) + .padding(.leading, 14) + } +} diff --git a/Sources/cmdcmd/Tile.swift b/Sources/cmdcmd/Tile.swift index afaf132..360c336 100644 --- a/Sources/cmdcmd/Tile.swift +++ b/Sources/cmdcmd/Tile.swift @@ -26,8 +26,13 @@ final class Tile: NSObject, SCStreamOutput, SCStreamDelegate { return NSColor(srgbRed: r, green: g, blue: b, alpha: 1) } - let scWindow: SCWindow + let scWindow: SCWindow? + let windowID: CGWindowID + let sourceFrame: CGRect + let sourceTitle: String? let ownerPID: pid_t + let ownerBundleIdentifier: String? + let ownerName: String let ignoreKey: String let layer: CALayer private let content: CALayer @@ -36,15 +41,22 @@ final class Tile: NSObject, SCStreamOutput, SCStreamDelegate { private let titlePill: CALayer private let titleText: CATextLayer private let idleDot: CALayer + private let minimalMode: Bool private var lastSignificantChangeAt: CFAbsoluteTime = CFAbsoluteTimeGetCurrent() private(set) var isIdle: Bool = false private var stream: SCStream? private var cancelled = false private let queue = DispatchQueue(label: "cmdcmd.tile", qos: .userInteractive) - init(scWindow: SCWindow, ownerPID: pid_t) { + init(scWindow: SCWindow, ownerPID: pid_t, minimalMode: Bool = false) { self.scWindow = scWindow + self.windowID = CGWindowID(scWindow.windowID) + self.sourceFrame = scWindow.frame + self.sourceTitle = scWindow.title self.ownerPID = ownerPID + self.ownerBundleIdentifier = scWindow.owningApplication?.bundleIdentifier + self.ownerName = scWindow.owningApplication?.applicationName ?? NSRunningApplication(processIdentifier: ownerPID)?.localizedName ?? "" + self.minimalMode = minimalMode let bid = scWindow.owningApplication?.bundleIdentifier ?? "" let title = scWindow.title ?? "" self.ignoreKey = "\(bid)|||\(title)" @@ -58,14 +70,14 @@ final class Tile: NSObject, SCStreamOutput, SCStreamDelegate { outer.borderColor = NSColor.clear.cgColor outer.borderWidth = 0 let inner = CALayer() - inner.backgroundColor = NSColor(white: 0.08, alpha: 1).cgColor - inner.cornerRadius = 9 - inner.contentsGravity = .resizeAspect + inner.backgroundColor = minimalMode ? NSColor.clear.cgColor : NSColor(white: 0.08, alpha: 1).cgColor + inner.cornerRadius = minimalMode ? 14 : 9 + inner.contentsGravity = minimalMode ? .resizeAspect : .resizeAspect inner.minificationFilter = .trilinear inner.magnificationFilter = .linear inner.masksToBounds = true - inner.borderColor = NSColor.white.withAlphaComponent(0.18).cgColor - inner.borderWidth = 1 + inner.borderColor = minimalMode ? NSColor.clear.cgColor : NSColor.white.withAlphaComponent(0.18).cgColor + inner.borderWidth = minimalMode ? 0 : 1 outer.addSublayer(inner) let chip = CALayer() @@ -116,6 +128,90 @@ final class Tile: NSObject, SCStreamOutput, SCStreamDelegate { self.idleDot = dot self.windowTitle = scWindow.title ?? "" super.init() + if minimalMode { + installAppIcon() + } + } + + init(spaceWindow: SpaceWindow) { + self.scWindow = nil + self.windowID = spaceWindow.windowID + self.sourceFrame = spaceWindow.bounds + self.sourceTitle = spaceWindow.title + self.ownerPID = spaceWindow.ownerPID + self.ownerBundleIdentifier = NSRunningApplication(processIdentifier: spaceWindow.ownerPID)?.bundleIdentifier + self.ownerName = spaceWindow.ownerName + self.minimalMode = true + self.ignoreKey = "\(spaceWindow.ownerName)|||\(spaceWindow.title)" + + let outer = CALayer() + outer.masksToBounds = false + outer.shadowOpacity = 0 + outer.shadowRadius = 12 + outer.shadowOffset = .zero + outer.cornerRadius = 10 + outer.borderColor = NSColor.clear.cgColor + outer.borderWidth = 0 + let inner = CALayer() + inner.backgroundColor = NSColor.clear.cgColor + inner.cornerRadius = 14 + inner.contentsGravity = .resizeAspect + inner.minificationFilter = .trilinear + inner.magnificationFilter = .linear + inner.masksToBounds = true + inner.borderColor = NSColor.clear.cgColor + inner.borderWidth = 0 + outer.addSublayer(inner) + + let chip = CALayer() + chip.backgroundColor = NSColor.black.withAlphaComponent(0.55).cgColor + chip.masksToBounds = true + chip.isHidden = true + inner.addSublayer(chip) + + let dot = CALayer() + dot.backgroundColor = NSColor.white.withAlphaComponent(0.85).cgColor + dot.cornerRadius = 5 + dot.opacity = 0 + inner.addSublayer(dot) + + let chipText = CATextLayer() + chipText.alignmentMode = .center + chipText.foregroundColor = NSColor.white.cgColor + chipText.backgroundColor = NSColor.clear.cgColor + chipText.font = NSFont.systemFont(ofSize: 12, weight: .bold) + chipText.fontSize = 12 + chipText.contentsScale = NSScreen.main?.backingScaleFactor ?? 2 + chipText.string = "" + chip.addSublayer(chipText) + + let pill = CALayer() + pill.backgroundColor = NSColor.black.withAlphaComponent(0.55).cgColor + pill.masksToBounds = true + pill.isHidden = true + inner.addSublayer(pill) + + let pillText = CATextLayer() + pillText.alignmentMode = .left + pillText.truncationMode = .end + pillText.foregroundColor = NSColor.white.withAlphaComponent(0.85).cgColor + pillText.backgroundColor = NSColor.clear.cgColor + pillText.font = NSFont.systemFont(ofSize: 12, weight: .semibold) + pillText.fontSize = 12 + pillText.contentsScale = NSScreen.main?.backingScaleFactor ?? 2 + pillText.string = "" + pill.addSublayer(pillText) + + self.layer = outer + self.content = inner + self.numberChip = chip + self.numberText = chipText + self.titlePill = pill + self.titleText = pillText + self.idleDot = dot + self.windowTitle = spaceWindow.title + super.init() + installAppIcon() } var tintColorName: String? { @@ -134,6 +230,16 @@ final class Tile: NSObject, SCStreamOutput, SCStreamDelegate { } private let windowTitle: String + + private func installAppIcon() { + guard let app = NSRunningApplication(processIdentifier: ownerPID) else { return } + let icon = app.icon ?? NSWorkspace.shared.icon(forFile: app.bundleURL?.path ?? "") + let size: CGFloat = 64 + icon.size = NSSize(width: size, height: size) + content.contents = icon + content.contentsGravity = .resizeAspect + content.contentsScale = NSScreen.main?.backingScaleFactor ?? 2 + } private var currentNumber: Int? func setNumber(_ n: Int?) { @@ -143,33 +249,42 @@ final class Tile: NSObject, SCStreamOutput, SCStreamDelegate { private func updateLabel() { let trimmed = windowTitle.trimmingCharacters(in: .whitespacesAndNewlines) - if let n = currentNumber { + if let n = currentNumber, !minimalMode { numberText.string = "\(n)" numberChip.isHidden = false } else { numberText.string = "" numberChip.isHidden = true } - titleText.string = trimmed - titlePill.isHidden = trimmed.isEmpty + titleText.string = minimalMode ? "" : trimmed + titlePill.isHidden = minimalMode || trimmed.isEmpty layoutLabel() } func setFrame(_ rect: CGRect) { layer.frame = rect - content.frame = CGRect(origin: .zero, size: rect.size).insetBy(dx: 1, dy: 1) - layer.shadowPath = CGPath(roundedRect: CGRect(origin: .zero, size: rect.size), cornerWidth: 10, cornerHeight: 10, transform: nil) + if minimalMode { + let iconSide = min(rect.width, rect.height) * 0.68 + content.frame = CGRect(x: (rect.width - iconSide) / 2, y: (rect.height - iconSide) / 2, width: iconSide, height: iconSide) + } else { + content.frame = CGRect(origin: .zero, size: rect.size).insetBy(dx: 1, dy: 1) + } + layer.shadowPath = CGPath(roundedRect: CGRect(origin: .zero, size: rect.size), cornerWidth: minimalMode ? 18 : 10, cornerHeight: minimalMode ? 18 : 10, transform: nil) layoutLabel() } private func layoutLabel() { let rect = content.bounds guard rect.width > 0 else { return } - let badgeHeight: CGFloat = 22 - let inset: CGFloat = 8 - let gap: CGFloat = 6 - let hPad: CGFloat = 8 - let font = NSFont.systemFont(ofSize: 12, weight: .semibold) + let badgeHeight: CGFloat = minimalMode ? 18 : 22 + let inset: CGFloat = minimalMode ? 2 : 8 + let gap: CGFloat = minimalMode ? 4 : 6 + let hPad: CGFloat = minimalMode ? 6 : 8 + let font = NSFont.systemFont(ofSize: minimalMode ? 10 : 12, weight: .semibold) + numberText.font = font + numberText.fontSize = font.pointSize + titleText.font = font + titleText.fontSize = font.pointSize let lineHeight = ceil(font.ascender - font.descender) let textY = (badgeHeight - lineHeight) / 2 @@ -191,7 +306,7 @@ final class Tile: NSObject, SCStreamOutput, SCStreamDelegate { ) } - let dotSize: CGFloat = 10 + let dotSize: CGFloat = minimalMode ? 7 : 10 idleDot.frame = CGRect( x: rect.size.width - dotSize - inset, y: chipFrame.midY - dotSize / 2, @@ -236,24 +351,27 @@ final class Tile: NSObject, SCStreamOutput, SCStreamDelegate { CATransaction.setDisableActions(true) switch highlight { case .none: - content.borderColor = NSColor.white.withAlphaComponent(0.18).cgColor - content.borderWidth = 1 + content.borderColor = minimalMode ? NSColor.clear.cgColor : NSColor.white.withAlphaComponent(0.18).cgColor + content.borderWidth = minimalMode ? 0 : 1 + layer.backgroundColor = NSColor.clear.cgColor layer.borderColor = NSColor.clear.cgColor layer.borderWidth = 0 layer.shadowOpacity = 0 case .subtle: content.borderColor = NSColor.clear.cgColor content.borderWidth = 0 - layer.borderColor = NSColor.controlAccentColor.cgColor - layer.borderWidth = 3 - layer.shadowColor = NSColor.controlAccentColor.cgColor - layer.shadowOpacity = 0.6 + layer.backgroundColor = minimalMode ? NSColor.white.withAlphaComponent(0.13).cgColor : NSColor.clear.cgColor + layer.borderColor = minimalMode ? NSColor.white.withAlphaComponent(0.24).cgColor : NSColor.controlAccentColor.cgColor + layer.borderWidth = minimalMode ? 0.75 : 3 + layer.shadowColor = (minimalMode ? NSColor.black : NSColor.controlAccentColor).cgColor + layer.shadowOpacity = minimalMode ? 0.28 : 0.6 } CATransaction.commit() } func start() async { - if cancelled { return } + if cancelled || minimalMode { return } + guard let scWindow else { return } let filter = SCContentFilter(desktopIndependentWindow: scWindow) let config = SCStreamConfiguration() let scale = NSScreen.main?.backingScaleFactor ?? 2 @@ -276,7 +394,7 @@ final class Tile: NSObject, SCStreamOutput, SCStreamDelegate { } self.stream = s } catch { - Log.write("tile start failed wid=\(scWindow.windowID): \(error)") + Log.write("tile start failed wid=\(windowID): \(error)") } } diff --git a/Sources/cmdcmd/main.swift b/Sources/cmdcmd/main.swift index e32cbf7..72f53e8 100644 --- a/Sources/cmdcmd/main.swift +++ b/Sources/cmdcmd/main.swift @@ -2,31 +2,34 @@ import AppKit import CoreGraphics import ScreenCaptureKit -let args = CommandLine.arguments -if let i = args.firstIndex(of: "--render-iconset"), i + 1 < args.count { - let url = URL(fileURLWithPath: args[i + 1]) - do { - try AppIcon.writeIconset(to: url) - exit(0) - } catch { - FileHandle.standardError.write(Data("render-iconset failed: \(error)\n".utf8)) - exit(1) - } -} - -let app = NSApplication.shared -app.setActivationPolicy(.regular) -app.applicationIconImage = AppIcon.makePlaceholder() +final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { + var settingsFactory: (() -> SettingsWindowController)? + private var settingsWindow: SettingsWindowController? + private var statusItem: NSStatusItem? + private var displayMode: DisplayMode = .dock -final class AppDelegate: NSObject, NSApplicationDelegate { func applicationDockMenu(_ sender: NSApplication) -> NSMenu? { let menu = NSMenu() + let settings = NSMenuItem(title: "Settings…", action: #selector(openSettings), keyEquivalent: "") + settings.target = self + menu.addItem(settings) let item = NSMenuItem(title: "Open Config…", action: #selector(openConfig), keyEquivalent: "") item.target = self menu.addItem(item) return menu } + @objc func openSettings() { + if displayMode == .hidden { + NSApp.setActivationPolicy(.regular) + } + let controller = settingsWindow ?? settingsFactory?() + settingsWindow = controller + controller?.window?.delegate = self + controller?.showWindow(nil) + NSApp.activate(ignoringOtherApps: true) + } + @objc func openConfig() { do { let url = try Config.ensureExists() @@ -35,64 +38,156 @@ final class AppDelegate: NSObject, NSApplicationDelegate { Log.write("openConfig failed: \(error)") } } -} -let appDelegate = AppDelegate() -app.delegate = appDelegate -app.finishLaunching() + func installMainMenu() { + let menu = NSMenu() + let appItem = NSMenuItem() + let appMenu = NSMenu() + appMenu.addItem(NSMenuItem(title: "Settings…", action: #selector(openSettings), keyEquivalent: ",")) + appMenu.addItem(NSMenuItem(title: "Open Config…", action: #selector(openConfig), keyEquivalent: "")) + appMenu.addItem(.separator()) + appMenu.addItem(NSMenuItem(title: "Quit cmdcmd", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")) + appItem.submenu = appMenu + menu.addItem(appItem) + NSApp.mainMenu = menu + } -let appConfig = Config.load() -let tracker = SpaceTracker() -let overlay = Overlay(tracker: tracker, config: appConfig) -var trigger: AnyObject? + func applyDisplayMode(_ mode: DisplayMode) { + displayMode = mode + switch mode { + case .dock: + statusItem = nil + NSApp.setActivationPolicy(.regular) + case .menuBar: + NSApp.setActivationPolicy(.accessory) + installStatusItem() + case .hidden: + statusItem = nil + NSApp.setActivationPolicy(.prohibited) + } + } -func startApp() { - Task { - _ = try? await SCShareableContent.current + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { + openSettings() + return true } - let fire = { - overlay.toggle() - dumpState(tracker: tracker) + + func windowWillClose(_ notification: Notification) { + if displayMode == .hidden { + NSApp.setActivationPolicy(.prohibited) + } } - if appConfig.triggerSpec.lowercased() == "cmd-cmd" { - trigger = CmdChord(handler: fire) - } else if let monitor = HotkeyMonitor(spec: appConfig.triggerSpec, handler: fire) { - trigger = monitor - Log.write("trigger = \(appConfig.triggerSpec)") - } else { - Log.write("trigger spec '\(appConfig.triggerSpec)' invalid; falling back to cmd-cmd") - trigger = CmdChord(handler: fire) + + private func installStatusItem() { + if statusItem == nil { + statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) + } + statusItem?.button?.image = AppIcon.makePlaceholder() + statusItem?.button?.image?.size = NSSize(width: 18, height: 18) + let menu = NSMenu() + let settings = NSMenuItem(title: "Settings…", action: #selector(openSettings), keyEquivalent: ",") + settings.target = self + menu.addItem(settings) + let config = NSMenuItem(title: "Open Config…", action: #selector(openConfig), keyEquivalent: "") + config.target = self + menu.addItem(config) + menu.addItem(.separator()) + menu.addItem(NSMenuItem(title: "Quit cmdcmd", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")) + statusItem?.menu = menu } - dumpState(tracker: tracker) } -let onboarding = Onboarding(onComplete: startApp) -if !onboarding.showIfNeeded() { - startApp() -} +@main +struct CmdcmdApp { + static var appConfig = Config.load() + static let appDelegate = AppDelegate() + static let tracker = SpaceTracker() + static let overlay = Overlay(tracker: tracker, config: appConfig) + static var trigger: AnyObject? -NotificationCenter.default.addObserver( - forName: NSApplication.willTerminateNotification, - object: nil, - queue: .main -) { _ in - overlay.shutdown() -} + static func main() { + let args = CommandLine.arguments + if let i = args.firstIndex(of: "--render-iconset"), i + 1 < args.count { + let url = URL(fileURLWithPath: args[i + 1]) + do { + try AppIcon.writeIconset(to: url) + exit(0) + } catch { + FileHandle.standardError.write(Data("render-iconset failed: \(error)\n".utf8)) + exit(1) + } + } + + let app = NSApplication.shared + app.applicationIconImage = AppIcon.makePlaceholder() + app.delegate = appDelegate + app.finishLaunching() + appDelegate.installMainMenu() + appDelegate.applyDisplayMode(appConfig.displayMode) + appDelegate.settingsFactory = { + let controller = SettingsWindowController(config: appConfig) + controller.onSave = { newConfig in + appConfig = newConfig + overlay.updateConfig(newConfig) + appDelegate.applyDisplayMode(newConfig.displayMode) + if !newConfig.minimalMode { + _ = Onboarding(onComplete: {}).showIfNeeded() + } + } + return controller + } + + let onboarding = Onboarding(onComplete: startApp) + if appConfig.minimalMode || !onboarding.showIfNeeded() { + startApp() + } + + NotificationCenter.default.addObserver( + forName: NSApplication.willTerminateNotification, + object: nil, + queue: .main + ) { _ in + overlay.shutdown() + } + + app.run() + } -func dumpState(tracker: SpaceTracker) { - let spaces = tracker.spaces() - let windows = tracker.windows() - print("--- spaces (\(spaces.count)) ---") - for s in spaces { - let active = s.isActive ? " *" : "" - print(" \(s.id) [\(s.type)] display=\(s.displayUUID.prefix(8))\(active)") + static func startApp() { + if !appConfig.minimalMode { + Task { + _ = try? await SCShareableContent.current + } + } + let fire = { + overlay.toggle() + dumpState(tracker: tracker) + } + if appConfig.triggerSpec.lowercased() == "cmd-cmd" { + trigger = CmdChord(handler: fire) + } else if let monitor = HotkeyMonitor(spec: appConfig.triggerSpec, handler: fire) { + trigger = monitor + Log.write("trigger = \(appConfig.triggerSpec)") + } else { + Log.write("trigger spec '\(appConfig.triggerSpec)' invalid; falling back to cmd-cmd") + trigger = CmdChord(handler: fire) + } + dumpState(tracker: tracker) } - print("--- windows (\(windows.count)) ---") - for w in windows where !w.ownerName.isEmpty { - let space = w.spaceID.map(String.init) ?? "-" - print(" \(w.windowID) space=\(space) \(w.ownerName) :: \(w.title)") + + static func dumpState(tracker: SpaceTracker) { + let spaces = tracker.spaces() + let windows = tracker.windows() + print("--- spaces (\(spaces.count)) ---") + for s in spaces { + let active = s.isActive ? " *" : "" + print(" \(s.id) [\(s.type)] display=\(s.displayUUID.prefix(8))\(active)") + } + print("--- windows (\(windows.count)) ---") + for w in windows where !w.ownerName.isEmpty { + let space = w.spaceID.map(String.init) ?? "-" + print(" \(w.windowID) space=\(space) \(w.ownerName) :: \(w.title)") + } + fflush(stdout) } - fflush(stdout) } - -app.run()