Refactor and Rename to CommandNotch
This commit is contained in:
241
Downterm/CommandNotch/Managers/HotkeyManager.swift
Normal file
241
Downterm/CommandNotch/Managers/HotkeyManager.swift
Normal file
@@ -0,0 +1,241 @@
|
||||
import AppKit
|
||||
import Carbon.HIToolbox
|
||||
|
||||
/// Manages global and local hotkeys.
|
||||
///
|
||||
/// The toggle hotkey uses Carbon's `RegisterEventHotKey` which works
|
||||
/// system-wide without Accessibility permission. Tab-level hotkeys
|
||||
/// use a local `NSEvent` monitor (only fires when our app is active).
|
||||
class HotkeyManager {
|
||||
|
||||
static let shared = HotkeyManager()
|
||||
|
||||
// MARK: - Callbacks
|
||||
|
||||
var onToggle: (() -> Void)?
|
||||
var onNewTab: (() -> Void)?
|
||||
var onCloseTab: (() -> Void)?
|
||||
var onNextTab: (() -> Void)?
|
||||
var onPreviousTab: (() -> Void)?
|
||||
var onDetachTab: (() -> Void)?
|
||||
var onSwitchToTab: ((Int) -> Void)?
|
||||
|
||||
/// Tab-level hotkeys only fire when the notch is open.
|
||||
var isNotchOpen: Bool = false
|
||||
|
||||
private var hotKeyRef: EventHotKeyRef?
|
||||
private var eventHandlerRef: EventHandlerRef?
|
||||
private var localMonitor: Any?
|
||||
private var defaultsObserver: NSObjectProtocol?
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Resolved bindings (live from UserDefaults)
|
||||
|
||||
private var toggleBinding: HotkeyBinding {
|
||||
binding(for: NotchSettings.Keys.hotkeyToggle) ?? .cmdReturn
|
||||
}
|
||||
private var newTabBinding: HotkeyBinding {
|
||||
binding(for: NotchSettings.Keys.hotkeyNewTab) ?? .cmdT
|
||||
}
|
||||
private var closeTabBinding: HotkeyBinding {
|
||||
binding(for: NotchSettings.Keys.hotkeyCloseTab) ?? .cmdW
|
||||
}
|
||||
private var nextTabBinding: HotkeyBinding {
|
||||
binding(for: NotchSettings.Keys.hotkeyNextTab) ?? .cmdShiftRB
|
||||
}
|
||||
private var prevTabBinding: HotkeyBinding {
|
||||
binding(for: NotchSettings.Keys.hotkeyPreviousTab) ?? .cmdShiftLB
|
||||
}
|
||||
private var detachBinding: HotkeyBinding {
|
||||
binding(for: NotchSettings.Keys.hotkeyDetachTab) ?? .cmdD
|
||||
}
|
||||
|
||||
private func binding(for key: String) -> HotkeyBinding? {
|
||||
guard let json = UserDefaults.standard.string(forKey: key) else { return nil }
|
||||
return HotkeyBinding.fromJSON(json)
|
||||
}
|
||||
|
||||
// MARK: - Start / Stop
|
||||
|
||||
func start() {
|
||||
installCarbonHandler()
|
||||
registerToggleHotkey()
|
||||
installLocalMonitor()
|
||||
observeToggleHotkeyChanges()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
unregisterToggleHotkey()
|
||||
removeCarbonHandler()
|
||||
removeLocalMonitor()
|
||||
if let obs = defaultsObserver {
|
||||
NotificationCenter.default.removeObserver(obs)
|
||||
defaultsObserver = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Carbon global hotkey (toggle)
|
||||
|
||||
/// Installs a Carbon event handler that receives `kEventHotKeyPressed`
|
||||
/// events when a registered hotkey fires — works system-wide.
|
||||
private func installCarbonHandler() {
|
||||
var eventType = EventTypeSpec(
|
||||
eventClass: OSType(kEventClassKeyboard),
|
||||
eventKind: UInt32(kEventHotKeyPressed)
|
||||
)
|
||||
|
||||
// Closure must not capture self — uses the singleton accessor instead.
|
||||
let status = InstallEventHandler(
|
||||
GetApplicationEventTarget(),
|
||||
{ (_: EventHandlerCallRef?, theEvent: EventRef?, _: UnsafeMutableRawPointer?) -> OSStatus in
|
||||
guard let theEvent else { return OSStatus(eventNotHandledErr) }
|
||||
|
||||
var hotKeyID = EventHotKeyID()
|
||||
let err = GetEventParameter(
|
||||
theEvent,
|
||||
EventParamName(kEventParamDirectObject),
|
||||
EventParamType(typeEventHotKeyID),
|
||||
nil,
|
||||
MemoryLayout<EventHotKeyID>.size,
|
||||
nil,
|
||||
&hotKeyID
|
||||
)
|
||||
guard err == noErr else { return err }
|
||||
|
||||
if hotKeyID.id == 1 {
|
||||
DispatchQueue.main.async {
|
||||
HotkeyManager.shared.onToggle?()
|
||||
}
|
||||
}
|
||||
return noErr
|
||||
},
|
||||
1,
|
||||
&eventType,
|
||||
nil,
|
||||
&eventHandlerRef
|
||||
)
|
||||
|
||||
if status != noErr {
|
||||
print("[HotkeyManager] Failed to install Carbon event handler: \(status)")
|
||||
}
|
||||
}
|
||||
|
||||
private func registerToggleHotkey() {
|
||||
unregisterToggleHotkey()
|
||||
|
||||
let binding = toggleBinding
|
||||
let carbonMods = carbonModifiers(from: binding.modifiers)
|
||||
var hotKeyID = EventHotKeyID(
|
||||
signature: OSType(0x444E5452), // "DNTR"
|
||||
id: 1
|
||||
)
|
||||
|
||||
let status = RegisterEventHotKey(
|
||||
UInt32(binding.keyCode),
|
||||
carbonMods,
|
||||
hotKeyID,
|
||||
GetApplicationEventTarget(),
|
||||
0,
|
||||
&hotKeyRef
|
||||
)
|
||||
|
||||
if status != noErr {
|
||||
print("[HotkeyManager] Failed to register toggle hotkey: \(status)")
|
||||
}
|
||||
}
|
||||
|
||||
private func unregisterToggleHotkey() {
|
||||
if let ref = hotKeyRef {
|
||||
UnregisterEventHotKey(ref)
|
||||
hotKeyRef = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func removeCarbonHandler() {
|
||||
if let ref = eventHandlerRef {
|
||||
RemoveEventHandler(ref)
|
||||
eventHandlerRef = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-register the toggle hotkey whenever the user changes it in settings.
|
||||
private func observeToggleHotkeyChanges() {
|
||||
defaultsObserver = NotificationCenter.default.addObserver(
|
||||
forName: UserDefaults.didChangeNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
self?.registerToggleHotkey()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Local monitor (tab-level hotkeys, only when our app is active)
|
||||
|
||||
private func installLocalMonitor() {
|
||||
localMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
|
||||
guard let self else { return event }
|
||||
return self.handleLocalKeyEvent(event) ? nil : event
|
||||
}
|
||||
}
|
||||
|
||||
private func removeLocalMonitor() {
|
||||
if let m = localMonitor {
|
||||
NSEvent.removeMonitor(m)
|
||||
localMonitor = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles tab-level hotkeys. Returns true if the event was consumed.
|
||||
private func handleLocalKeyEvent(_ event: NSEvent) -> Bool {
|
||||
// Tab hotkeys only when the notch is open and focused
|
||||
guard isNotchOpen else { return false }
|
||||
|
||||
if newTabBinding.matches(event) {
|
||||
onNewTab?()
|
||||
return true
|
||||
}
|
||||
if closeTabBinding.matches(event) {
|
||||
onCloseTab?()
|
||||
return true
|
||||
}
|
||||
if nextTabBinding.matches(event) {
|
||||
onNextTab?()
|
||||
return true
|
||||
}
|
||||
if prevTabBinding.matches(event) {
|
||||
onPreviousTab?()
|
||||
return true
|
||||
}
|
||||
if detachBinding.matches(event) {
|
||||
onDetachTab?()
|
||||
return true
|
||||
}
|
||||
|
||||
// Cmd+1 through Cmd+9
|
||||
if event.modifierFlags.contains(.command) {
|
||||
let digitKeyCodes: [UInt16: Int] = [
|
||||
18: 0, 19: 1, 20: 2, 21: 3, 23: 4,
|
||||
22: 5, 26: 6, 28: 7, 25: 8
|
||||
]
|
||||
if let tabIndex = digitKeyCodes[event.keyCode] {
|
||||
onSwitchToTab?(tabIndex)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// MARK: - Carbon modifier conversion
|
||||
|
||||
private func carbonModifiers(from nsModifiers: UInt) -> UInt32 {
|
||||
var carbon: UInt32 = 0
|
||||
let flags = NSEvent.ModifierFlags(rawValue: nsModifiers)
|
||||
if flags.contains(.command) { carbon |= UInt32(cmdKey) }
|
||||
if flags.contains(.shift) { carbon |= UInt32(shiftKey) }
|
||||
if flags.contains(.option) { carbon |= UInt32(optionKey) }
|
||||
if flags.contains(.control) { carbon |= UInt32(controlKey) }
|
||||
return carbon
|
||||
}
|
||||
}
|
||||
24
Downterm/CommandNotch/Managers/LaunchAtLoginHelper.swift
Normal file
24
Downterm/CommandNotch/Managers/LaunchAtLoginHelper.swift
Normal file
@@ -0,0 +1,24 @@
|
||||
import ServiceManagement
|
||||
|
||||
/// Registers / unregisters the app as a login item using the
|
||||
/// modern SMAppService API (macOS 13+).
|
||||
enum LaunchAtLoginHelper {
|
||||
|
||||
static func setEnabled(_ enabled: Bool) {
|
||||
let service = SMAppService.mainApp
|
||||
do {
|
||||
if enabled {
|
||||
try service.register()
|
||||
} else {
|
||||
try service.unregister()
|
||||
}
|
||||
} catch {
|
||||
print("[LaunchAtLogin] Failed to \(enabled ? "register" : "unregister"): \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the current registration state from the system.
|
||||
static var isEnabled: Bool {
|
||||
SMAppService.mainApp.status == .enabled
|
||||
}
|
||||
}
|
||||
87
Downterm/CommandNotch/Managers/PopoutWindowController.swift
Normal file
87
Downterm/CommandNotch/Managers/PopoutWindowController.swift
Normal file
@@ -0,0 +1,87 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
/// Manages standalone pop-out terminal windows for detached tabs.
|
||||
/// Each detached tab gets its own resizable window with the terminal view.
|
||||
@MainActor
|
||||
class PopoutWindowController: NSObject, NSWindowDelegate {
|
||||
|
||||
static let shared = PopoutWindowController()
|
||||
|
||||
/// Tracks open pop-out windows so they aren't released prematurely.
|
||||
private var windows: [UUID: NSWindow] = [:]
|
||||
private var sessions: [UUID: TerminalSession] = [:]
|
||||
private var titleObservers: [UUID: AnyCancellable] = [:]
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
/// Creates a new standalone window for the given terminal session.
|
||||
func popout(session: TerminalSession) {
|
||||
let windowID = session.id
|
||||
|
||||
if let existingWindow = windows[windowID] {
|
||||
existingWindow.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
return
|
||||
}
|
||||
|
||||
let win = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 720, height: 480),
|
||||
styleMask: [.titled, .closable, .resizable, .miniaturizable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
win.title = session.title
|
||||
win.appearance = NSAppearance(named: .darkAqua)
|
||||
win.backgroundColor = .black
|
||||
win.delegate = self
|
||||
win.isReleasedWhenClosed = false
|
||||
|
||||
let hostingView = NSHostingView(
|
||||
rootView: SwiftTermView(session: session)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color.black)
|
||||
.preferredColorScheme(.dark)
|
||||
)
|
||||
hostingView.frame = NSRect(origin: .zero, size: win.contentRect(forFrameRect: win.frame).size)
|
||||
win.contentView = hostingView
|
||||
|
||||
win.center()
|
||||
win.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
|
||||
windows[windowID] = win
|
||||
sessions[windowID] = session
|
||||
|
||||
// Update window title when the terminal title changes
|
||||
titleObservers[windowID] = session.$title
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak win] title in win?.title = title }
|
||||
}
|
||||
|
||||
// MARK: - NSWindowDelegate
|
||||
|
||||
func windowDidBecomeKey(_ notification: Notification) {
|
||||
guard let window = notification.object as? NSWindow,
|
||||
let entry = windows.first(where: { $0.value === window }),
|
||||
let terminalView = sessions[entry.key]?.terminalView,
|
||||
terminalView.window === window else { return }
|
||||
|
||||
window.makeFirstResponder(terminalView)
|
||||
}
|
||||
|
||||
func windowWillClose(_ notification: Notification) {
|
||||
guard let closingWindow = notification.object as? NSWindow else { return }
|
||||
|
||||
// Find which session this window belongs to and clean up
|
||||
if let entry = windows.first(where: { $0.value === closingWindow }) {
|
||||
sessions[entry.key]?.terminate()
|
||||
sessions.removeValue(forKey: entry.key)
|
||||
windows.removeValue(forKey: entry.key)
|
||||
titleObservers.removeValue(forKey: entry.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
268
Downterm/CommandNotch/Managers/ScreenManager.swift
Normal file
268
Downterm/CommandNotch/Managers/ScreenManager.swift
Normal file
@@ -0,0 +1,268 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
/// Manages one NotchWindow per connected display.
|
||||
/// Routes all open/close through centralized methods that handle
|
||||
/// window activation, key status, and first responder assignment
|
||||
/// so the terminal can receive keyboard input.
|
||||
@MainActor
|
||||
class ScreenManager: ObservableObject {
|
||||
|
||||
static let shared = ScreenManager()
|
||||
private let focusRetryDelay: TimeInterval = 0.01
|
||||
|
||||
private(set) var windows: [String: NotchWindow] = [:]
|
||||
private(set) var viewModels: [String: NotchViewModel] = [:]
|
||||
|
||||
@AppStorage(NotchSettings.Keys.showOnAllDisplays)
|
||||
private var showOnAllDisplays = NotchSettings.Defaults.showOnAllDisplays
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
func start() {
|
||||
observeScreenChanges()
|
||||
rebuildWindows()
|
||||
setupHotkeys()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
cleanupAllWindows()
|
||||
cancellables.removeAll()
|
||||
HotkeyManager.shared.stop()
|
||||
}
|
||||
|
||||
// MARK: - Hotkey wiring
|
||||
|
||||
private func setupHotkeys() {
|
||||
let hk = HotkeyManager.shared
|
||||
let tm = TerminalManager.shared
|
||||
|
||||
// Callbacks are invoked on the main thread by HotkeyManager.
|
||||
// MainActor.assumeIsolated lets us safely call @MainActor methods.
|
||||
hk.onToggle = { [weak self] in
|
||||
MainActor.assumeIsolated { self?.toggleNotchOnActiveScreen() }
|
||||
}
|
||||
hk.onNewTab = { MainActor.assumeIsolated { tm.newTab() } }
|
||||
hk.onCloseTab = { MainActor.assumeIsolated { tm.closeActiveTab() } }
|
||||
hk.onNextTab = { MainActor.assumeIsolated { tm.nextTab() } }
|
||||
hk.onPreviousTab = { MainActor.assumeIsolated { tm.previousTab() } }
|
||||
hk.onDetachTab = { [weak self] in
|
||||
MainActor.assumeIsolated { self?.detachActiveTab() }
|
||||
}
|
||||
hk.onSwitchToTab = { index in
|
||||
MainActor.assumeIsolated { tm.switchToTab(at: index) }
|
||||
}
|
||||
|
||||
hk.start()
|
||||
}
|
||||
|
||||
// MARK: - Toggle
|
||||
|
||||
func toggleNotchOnActiveScreen() {
|
||||
let mouseLocation = NSEvent.mouseLocation
|
||||
let targetScreen = NSScreen.screens.first { NSMouseInRect(mouseLocation, $0.frame, false) }
|
||||
?? NSScreen.main
|
||||
guard let screen = targetScreen else { return }
|
||||
let uuid = screen.displayUUID
|
||||
|
||||
// Close any other open notch first
|
||||
for (otherUUID, otherVM) in viewModels where otherUUID != uuid {
|
||||
if otherVM.notchState == .open {
|
||||
closeNotch(screenUUID: otherUUID)
|
||||
}
|
||||
}
|
||||
|
||||
if let vm = viewModels[uuid] {
|
||||
if vm.notchState == .open {
|
||||
closeNotch(screenUUID: uuid)
|
||||
} else {
|
||||
openNotch(screenUUID: uuid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Open / Close
|
||||
|
||||
func openNotch(screenUUID: String) {
|
||||
guard let vm = viewModels[screenUUID],
|
||||
let window = windows[screenUUID] else { return }
|
||||
|
||||
vm.cancelCloseTransition()
|
||||
|
||||
withAnimation(vm.openAnimation) {
|
||||
vm.open()
|
||||
}
|
||||
|
||||
window.isNotchOpen = true
|
||||
HotkeyManager.shared.isNotchOpen = true
|
||||
|
||||
// Activate the app so the window can become key.
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
|
||||
focusActiveTerminal(in: screenUUID)
|
||||
}
|
||||
|
||||
func closeNotch(screenUUID: String) {
|
||||
guard let vm = viewModels[screenUUID],
|
||||
let window = windows[screenUUID] else { return }
|
||||
|
||||
vm.beginCloseTransition()
|
||||
|
||||
withAnimation(vm.closeAnimation) {
|
||||
vm.close()
|
||||
}
|
||||
|
||||
window.isNotchOpen = false
|
||||
HotkeyManager.shared.isNotchOpen = false
|
||||
}
|
||||
|
||||
private func detachActiveTab() {
|
||||
if let session = TerminalManager.shared.detachActiveTab() {
|
||||
DispatchQueue.main.async {
|
||||
PopoutWindowController.shared.popout(session: session)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Window creation
|
||||
|
||||
func rebuildWindows() {
|
||||
cleanupAllWindows()
|
||||
|
||||
let screens: [NSScreen]
|
||||
if showOnAllDisplays {
|
||||
screens = NSScreen.screens
|
||||
} else {
|
||||
screens = [NSScreen.main].compactMap { $0 }
|
||||
}
|
||||
for screen in screens {
|
||||
createWindow(for: screen)
|
||||
}
|
||||
}
|
||||
|
||||
private func createWindow(for screen: NSScreen) {
|
||||
let uuid = screen.displayUUID
|
||||
let vm = NotchViewModel(screenUUID: uuid)
|
||||
|
||||
let shadowPadding: CGFloat = 20
|
||||
let openSize = vm.openNotchSize
|
||||
let windowWidth = max(openSize.width + 40, screen.frame.width * 0.5)
|
||||
let windowHeight = openSize.height + shadowPadding
|
||||
|
||||
let windowRect = NSRect(
|
||||
x: screen.frame.origin.x + (screen.frame.width - windowWidth) / 2,
|
||||
y: screen.frame.origin.y + screen.frame.height - windowHeight,
|
||||
width: windowWidth,
|
||||
height: windowHeight
|
||||
)
|
||||
|
||||
let window = NotchWindow(
|
||||
contentRect: windowRect,
|
||||
styleMask: [.borderless, .nonactivatingPanel, .utilityWindow],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
|
||||
// Close the notch when the window loses focus
|
||||
window.onResignKey = { [weak self] in
|
||||
self?.closeNotch(screenUUID: uuid)
|
||||
}
|
||||
|
||||
// Wire the ViewModel callbacks so ContentView routes through us
|
||||
vm.requestOpen = { [weak self] in
|
||||
self?.openNotch(screenUUID: uuid)
|
||||
}
|
||||
vm.requestClose = { [weak self] in
|
||||
self?.closeNotch(screenUUID: uuid)
|
||||
}
|
||||
|
||||
let hostingView = NSHostingView(
|
||||
rootView: ContentView(vm: vm, terminalManager: TerminalManager.shared)
|
||||
.preferredColorScheme(.dark)
|
||||
)
|
||||
hostingView.frame = NSRect(origin: .zero, size: windowRect.size)
|
||||
window.contentView = hostingView
|
||||
|
||||
window.setFrame(windowRect, display: true)
|
||||
window.orderFrontRegardless()
|
||||
|
||||
windows[uuid] = window
|
||||
viewModels[uuid] = vm
|
||||
}
|
||||
|
||||
// MARK: - Repositioning
|
||||
|
||||
func repositionWindows() {
|
||||
for (uuid, window) in windows {
|
||||
guard let screen = NSScreen.screens.first(where: { $0.displayUUID == uuid }) else { continue }
|
||||
guard let vm = viewModels[uuid] else { continue }
|
||||
|
||||
vm.refreshClosedSize()
|
||||
|
||||
let shadowPadding: CGFloat = 20
|
||||
let openSize = vm.openNotchSize
|
||||
let windowWidth = max(openSize.width + 40, screen.frame.width * 0.5)
|
||||
let windowHeight = openSize.height + shadowPadding
|
||||
|
||||
let newFrame = NSRect(
|
||||
x: screen.frame.origin.x + (screen.frame.width - windowWidth) / 2,
|
||||
y: screen.frame.origin.y + screen.frame.height - windowHeight,
|
||||
width: windowWidth,
|
||||
height: windowHeight
|
||||
)
|
||||
window.setFrame(newFrame, display: true)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cleanup
|
||||
|
||||
private func cleanupAllWindows() {
|
||||
for (_, window) in windows {
|
||||
window.orderOut(nil)
|
||||
window.close()
|
||||
}
|
||||
windows.removeAll()
|
||||
viewModels.removeAll()
|
||||
}
|
||||
|
||||
// MARK: - Screen observation
|
||||
|
||||
private func observeScreenChanges() {
|
||||
NotificationCenter.default.publisher(for: NSApplication.didChangeScreenParametersNotification)
|
||||
.debounce(for: .milliseconds(500), scheduler: RunLoop.main)
|
||||
.sink { [weak self] _ in self?.handleScreenConfigurationChange() }
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func handleScreenConfigurationChange() {
|
||||
let currentUUIDs = Set(NSScreen.screens.map { $0.displayUUID })
|
||||
let knownUUIDs = Set(windows.keys)
|
||||
if currentUUIDs != knownUUIDs {
|
||||
rebuildWindows()
|
||||
} else {
|
||||
repositionWindows()
|
||||
}
|
||||
}
|
||||
|
||||
private func focusActiveTerminal(in screenUUID: String, attemptsRemaining: Int = 12) {
|
||||
guard let window = windows[screenUUID],
|
||||
let terminalView = TerminalManager.shared.activeTab?.terminalView else { return }
|
||||
|
||||
if terminalView.window === window {
|
||||
window.makeFirstResponder(terminalView)
|
||||
return
|
||||
}
|
||||
|
||||
guard attemptsRemaining > 0 else { return }
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + focusRetryDelay) { [weak self] in
|
||||
self?.focusActiveTerminal(in: screenUUID, attemptsRemaining: attemptsRemaining - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// Singleton controller that manages the settings window.
|
||||
/// When the settings panel opens, the app becomes a regular app
|
||||
/// (visible in Dock / Cmd-Tab). When it closes, the app reverts
|
||||
/// to an accessory (menu-bar-only) app.
|
||||
class SettingsWindowController: NSObject, NSWindowDelegate {
|
||||
|
||||
static let shared = SettingsWindowController()
|
||||
|
||||
private var window: NSWindow?
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
// MARK: - Show / Hide
|
||||
|
||||
func showSettings() {
|
||||
if let existing = window {
|
||||
existing.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
return
|
||||
}
|
||||
|
||||
let settingsView = SettingsView()
|
||||
let hostingView = NSHostingView(rootView: settingsView)
|
||||
|
||||
let win = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 700, height: 500),
|
||||
styleMask: [.titled, .closable, .resizable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
win.title = "CommandNotch Settings"
|
||||
win.contentView = hostingView
|
||||
win.center()
|
||||
win.delegate = self
|
||||
win.isReleasedWhenClosed = false
|
||||
|
||||
// Appear in Dock while settings are open
|
||||
NSApp.setActivationPolicy(.regular)
|
||||
|
||||
win.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
|
||||
window = win
|
||||
}
|
||||
|
||||
// MARK: - NSWindowDelegate
|
||||
|
||||
func windowWillClose(_ notification: Notification) {
|
||||
// Revert to accessory (menu-bar-only) mode
|
||||
NSApp.setActivationPolicy(.accessory)
|
||||
window = nil
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user