Yep. AI rewrote the whole thing.

This commit is contained in:
2026-03-13 03:24:24 +11:00
parent e4719cb9f4
commit fe6c7d8c12
47 changed files with 5348 additions and 1182 deletions

View File

@@ -4,22 +4,36 @@ import Combine
/// Application delegate that bootstraps the notch overlay system.
@MainActor
class AppDelegate: NSObject, NSApplicationDelegate {
private enum UITestLaunchArgument {
static let regularActivation = "--uitest-regular-activation"
static let showSettings = "--uitest-show-settings"
static let openNotch = "--uitest-open-notch"
}
private var cancellables = Set<AnyCancellable>()
private let settingsController = AppSettingsController.shared
func applicationDidFinishLaunching(_ notification: Notification) {
NotchSettings.registerDefaults()
NSApp.setActivationPolicy(.accessory)
if isRunningUITests {
NSApp.setActivationPolicy(.regular)
} else {
NSApp.setActivationPolicy(.accessory)
}
// Sync the launch-at-login toggle with the actual system state
// in case the user toggled it from System Settings.
UserDefaults.standard.set(LaunchAtLoginHelper.isEnabled, forKey: NotchSettings.Keys.launchAtLogin)
settingsController.update {
$0.display.launchAtLogin = LaunchAtLoginHelper.isEnabled
}
ScreenManager.shared.start()
observeDisplayPreference()
observeSizePreferences()
observeFontSizeChanges()
observeTerminalThemeChanges()
applyUITestLaunchBehaviorIfNeeded()
}
func applicationWillTerminate(_ notification: Notification) {
@@ -30,7 +44,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
/// Only rebuild windows when the display-count preference changes.
private func observeDisplayPreference() {
UserDefaults.standard.publisher(for: \.showOnAllDisplays)
settingsController.$settings
.map(\.display.showOnAllDisplays)
.removeDuplicates()
.dropFirst()
.sink { _ in
@@ -41,7 +56,10 @@ class AppDelegate: NSObject, NSApplicationDelegate {
/// Reposition (not rebuild) when any sizing preference changes.
private func observeSizePreferences() {
NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)
settingsController.$settings
.map(\.display.layoutSignature)
.removeDuplicates()
.dropFirst()
.debounce(for: .milliseconds(300), scheduler: RunLoop.main)
.sink { _ in
ScreenManager.shared.repositionWindows()
@@ -51,38 +69,49 @@ class AppDelegate: NSObject, NSApplicationDelegate {
/// Live-update terminal font size across all sessions.
private func observeFontSizeChanges() {
UserDefaults.standard.publisher(for: \.terminalFontSize)
settingsController.$settings
.map(\.terminal.fontSize)
.removeDuplicates()
.sink { newSize in
guard newSize > 0 else { return }
TerminalManager.shared.updateAllFontSizes(CGFloat(newSize))
WorkspaceRegistry.shared.updateAllWorkspacesFontSizes(CGFloat(newSize))
}
.store(in: &cancellables)
}
/// Live-update terminal colors across all sessions.
private func observeTerminalThemeChanges() {
UserDefaults.standard.publisher(for: \.terminalTheme)
settingsController.$settings
.map(\.terminal.themeRawValue)
.removeDuplicates()
.sink { newTheme in
TerminalManager.shared.updateAllThemes(TerminalTheme.resolve(newTheme))
WorkspaceRegistry.shared.updateAllWorkspacesThemes(TerminalTheme.resolve(newTheme))
}
.store(in: &cancellables)
}
}
// MARK: - KVO key paths
private extension UserDefaults {
@objc var terminalFontSize: Double {
double(forKey: NotchSettings.Keys.terminalFontSize)
private var launchArguments: [String] {
ProcessInfo.processInfo.arguments
}
@objc var terminalTheme: String {
string(forKey: NotchSettings.Keys.terminalTheme) ?? NotchSettings.Defaults.terminalTheme
private var isRunningUITests: Bool {
launchArguments.contains(UITestLaunchArgument.regularActivation)
|| launchArguments.contains(UITestLaunchArgument.showSettings)
|| launchArguments.contains(UITestLaunchArgument.openNotch)
}
@objc var showOnAllDisplays: Bool {
bool(forKey: NotchSettings.Keys.showOnAllDisplays)
private func applyUITestLaunchBehaviorIfNeeded() {
guard isRunningUITests else { return }
DispatchQueue.main.async { @MainActor in
if self.launchArguments.contains(UITestLaunchArgument.showSettings) {
SettingsWindowController.shared.showSettings()
}
if self.launchArguments.contains(UITestLaunchArgument.openNotch),
let screenID = ScreenRegistry.shared.activeScreenID() {
ScreenManager.shared.openNotch(screenID: screenID)
}
}
}
}

View File

@@ -7,12 +7,19 @@ import SwiftUI
struct CommandNotchApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@AppStorage(NotchSettings.Keys.showMenuBarIcon)
private var showMenuBarIcon = NotchSettings.Defaults.showMenuBarIcon
@StateObject private var settingsController = AppSettingsController.shared
var body: some Scene {
MenuBarExtra("CommandNotch", systemImage: "terminal", isInserted: $showMenuBarIcon) {
MenuBarExtra(
"CommandNotch",
systemImage: "terminal",
isInserted: Binding(
get: { settingsController.settings.display.showMenuBarIcon },
set: { newValue in
settingsController.update { $0.display.showMenuBarIcon = newValue }
}
)
) {
Button("Toggle Notch") {
ScreenManager.shared.toggleNotchOnActiveScreen()
}

View File

@@ -5,13 +5,13 @@ import SwiftUI
/// the single `.opacity()` on ContentView handles transparency.
struct TabBar: View {
@ObservedObject var terminalManager: TerminalManager
@ObservedObject var workspace: WorkspaceController
var body: some View {
HStack(spacing: 0) {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 2) {
ForEach(Array(terminalManager.tabs.enumerated()), id: \.element.id) { index, tab in
ForEach(Array(workspace.tabs.enumerated()), id: \.element.id) { index, tab in
tabButton(for: tab, at: index)
}
}
@@ -21,12 +21,14 @@ struct TabBar: View {
Spacer()
Button {
terminalManager.newTab()
workspace.newTab()
} label: {
Image(systemName: "plus")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(.white.opacity(0.6))
}
.accessibilityLabel("New Tab")
.accessibilityIdentifier("notch.new-tab")
.buttonStyle(.plain)
.padding(.horizontal, 8)
}
@@ -36,7 +38,7 @@ struct TabBar: View {
@ViewBuilder
private func tabButton(for tab: TerminalSession, at index: Int) -> some View {
let isActive = index == terminalManager.activeTabIndex
let isActive = index == workspace.activeTabIndex
HStack(spacing: 4) {
Text(abbreviateTitle(tab.title))
@@ -44,9 +46,9 @@ struct TabBar: View {
.lineLimit(1)
.foregroundStyle(isActive ? .white : .white.opacity(0.5))
if isActive && terminalManager.tabs.count > 1 {
if isActive && workspace.tabs.count > 1 {
Button {
terminalManager.closeTab(at: index)
workspace.closeTab(at: index)
} label: {
Image(systemName: "xmark")
.font(.system(size: 8, weight: .bold))
@@ -63,7 +65,7 @@ struct TabBar: View {
)
.contentShape(Rectangle())
.onTapGesture {
terminalManager.switchToTab(at: index)
workspace.switchToTab(at: index)
}
}

View File

@@ -9,25 +9,11 @@ import SwiftTerm
/// layering, no mismatched areas.
struct ContentView: View {
@ObservedObject var vm: NotchViewModel
@ObservedObject var terminalManager: TerminalManager
@ObservedObject var screen: ScreenContext
let orchestrator: NotchOrchestrator
@ObservedObject private var settingsController = AppSettingsController.shared
@ObservedObject private var screenRegistry = ScreenRegistry.shared
// MARK: - Settings
@AppStorage(NotchSettings.Keys.openNotchOnHover) private var openNotchOnHover = NotchSettings.Defaults.openNotchOnHover
@AppStorage(NotchSettings.Keys.minimumHoverDuration) private var minimumHoverDuration = NotchSettings.Defaults.minimumHoverDuration
@AppStorage(NotchSettings.Keys.enableShadow) private var enableShadow = NotchSettings.Defaults.enableShadow
@AppStorage(NotchSettings.Keys.shadowRadius) private var shadowRadius = NotchSettings.Defaults.shadowRadius
@AppStorage(NotchSettings.Keys.shadowOpacity) private var shadowOpacity = NotchSettings.Defaults.shadowOpacity
@AppStorage(NotchSettings.Keys.cornerRadiusScaling) private var cornerRadiusScaling = NotchSettings.Defaults.cornerRadiusScaling
@AppStorage(NotchSettings.Keys.notchOpacity) private var notchOpacity = NotchSettings.Defaults.notchOpacity
@AppStorage(NotchSettings.Keys.blurRadius) private var blurRadius = NotchSettings.Defaults.blurRadius
@AppStorage(NotchSettings.Keys.hoverSpringResponse) private var hoverSpringResponse = NotchSettings.Defaults.hoverSpringResponse
@AppStorage(NotchSettings.Keys.hoverSpringDamping) private var hoverSpringDamping = NotchSettings.Defaults.hoverSpringDamping
@State private var hoverTask: Task<Void, Never>?
@State private var resizeStartSize: CGSize?
@State private var resizeStartMouseLocation: CGPoint?
@@ -36,18 +22,51 @@ struct ContentView: View {
}
private var currentShape: NotchShape {
vm.notchState == .open
screen.notchState == .open
? (cornerRadiusScaling ? .opened : NotchShape(topCornerRadius: 0, bottomCornerRadius: 14))
: .closed
}
private var enableShadow: Bool {
settingsController.settings.appearance.enableShadow
}
private var shadowRadius: Double {
settingsController.settings.appearance.shadowRadius
}
private var shadowOpacity: Double {
settingsController.settings.appearance.shadowOpacity
}
private var cornerRadiusScaling: Bool {
settingsController.settings.appearance.cornerRadiusScaling
}
private var notchOpacity: Double {
settingsController.settings.appearance.notchOpacity
}
private var blurRadius: Double {
settingsController.settings.appearance.blurRadius
}
private var hoverSpringResponse: Double {
settingsController.settings.animation.hoverSpringResponse
}
private var hoverSpringDamping: Double {
settingsController.settings.animation.hoverSpringDamping
}
// MARK: - Body
var body: some View {
notchBody
.accessibilityIdentifier("notch.container")
.frame(
width: vm.notchSize.width,
height: vm.notchState == .open ? vm.notchSize.height : vm.closedNotchSize.height,
width: screen.notchSize.width,
height: screen.notchState == .open ? screen.notchSize.height : screen.closedNotchSize.height,
alignment: .top
)
.background(.black)
@@ -56,7 +75,7 @@ struct ContentView: View {
Rectangle().fill(.black).frame(height: 1)
}
.overlay(alignment: .bottomTrailing) {
if vm.notchState == .open {
if screen.notchState == .open {
resizeHandle
}
}
@@ -68,22 +87,15 @@ struct ContentView: View {
// so this one modifier makes it all uniformly transparent.
.opacity(notchOpacity)
.blur(radius: blurRadius)
.animation(vm.notchState == .open ? vm.openAnimation : vm.closeAnimation, value: vm.notchState)
.animation(sizeAnimation, value: vm.notchSize.width)
.animation(sizeAnimation, value: vm.notchSize.height)
.animation(screen.notchState == .open ? screen.openAnimation : screen.closeAnimation, value: screen.notchState)
.animation(sizeAnimation, value: screen.notchSize.width)
.animation(sizeAnimation, value: screen.notchSize.height)
.onHover { handleHover($0) }
.onChange(of: vm.isCloseTransitionActive) { _, isClosing in
if isClosing {
hoverTask?.cancel()
} else {
scheduleHoverOpenIfNeeded()
}
}
.onDisappear {
hoverTask?.cancel()
resizeStartSize = nil
resizeStartMouseLocation = nil
vm.endInteractiveResize()
screen.endInteractiveResize()
orchestrator.handleHoverChange(false, for: screen.id)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.edgesIgnoringSafeArea(.all)
@@ -93,18 +105,20 @@ struct ContentView: View {
@ViewBuilder
private var notchBody: some View {
if vm.notchState == .open {
openContent
.transition(.opacity)
} else {
closedContent
WorkspaceScopedView(screen: screen, screenRegistry: screenRegistry) { workspace in
if screen.notchState == .open {
openContent(workspace: workspace)
.transition(.opacity)
} else {
closedContent(workspace: workspace)
}
}
}
private var closedContent: some View {
private func closedContent(workspace: WorkspaceController) -> some View {
HStack {
Spacer()
Text(abbreviate(terminalManager.activeTitle))
Text(abbreviate(workspace.activeTitle))
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.white.opacity(0.7))
.lineLimit(1)
@@ -128,15 +142,15 @@ struct ContentView: View {
DragGesture(minimumDistance: 0)
.onChanged { value in
if resizeStartSize == nil {
resizeStartSize = vm.notchSize
resizeStartSize = screen.notchSize
resizeStartMouseLocation = NSEvent.mouseLocation
vm.beginInteractiveResize()
screen.beginInteractiveResize()
}
guard let startSize = resizeStartSize,
let startMouseLocation = resizeStartMouseLocation else { return }
let currentMouseLocation = NSEvent.mouseLocation
vm.resizeOpenNotch(
screen.resizeOpenNotch(
to: CGSize(
width: startSize.width + ((currentMouseLocation.x - startMouseLocation.x) * 2),
height: startSize.height + (startMouseLocation.y - currentMouseLocation.y)
@@ -146,24 +160,25 @@ struct ContentView: View {
.onEnded { _ in
resizeStartSize = nil
resizeStartMouseLocation = nil
vm.endInteractiveResize()
screen.endInteractiveResize()
}
}
private var sizeAnimation: Animation? {
guard !vm.isUserResizing, !vm.isPresetResizing else { return nil }
return vm.notchState == .open ? vm.openAnimation : vm.closeAnimation
guard !screen.isUserResizing, !screen.isPresetResizing else { return nil }
return screen.notchState == .open ? screen.openAnimation : screen.closeAnimation
}
/// Open layout: VStack with toolbar row on top, terminal in the middle,
/// tab bar at the bottom. Every section has a black background.
private var openContent: some View {
private func openContent(workspace: WorkspaceController) -> some View {
VStack(spacing: 0) {
// Toolbar row right-aligned, solid black
HStack {
WorkspaceSwitcherView(screen: screen, orchestrator: orchestrator)
Spacer()
toolbarButton(icon: "arrow.up.forward.square", help: "Detach tab") {
if let session = terminalManager.detachActiveTab() {
if let session = workspace.detachActiveTab() {
PopoutWindowController.shared.popout(session: session)
}
}
@@ -172,12 +187,13 @@ struct ContentView: View {
}
}
.padding(.top, 6)
.padding(.leading, 10)
.padding(.trailing, 10)
.padding(.bottom, 2)
.background(.black)
// Terminal fills remaining space
if let session = terminalManager.activeTab {
if let session = workspace.activeTab {
SwiftTermView(session: session)
.id(session.id)
.padding(.leading, 10)
@@ -185,7 +201,7 @@ struct ContentView: View {
}
// Tab bar
TabBar(terminalManager: terminalManager)
TabBar(workspace: workspace)
}
.background(.black)
}
@@ -199,38 +215,16 @@ struct ContentView: View {
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(help)
.accessibilityIdentifier("notch.toolbar.\(icon)")
.help(help)
}
// MARK: - Hover
private func handleHover(_ hovering: Bool) {
if hovering {
withAnimation(hoverAnimation) { vm.isHovering = true }
scheduleHoverOpenIfNeeded()
} else {
hoverTask?.cancel()
withAnimation(hoverAnimation) { vm.isHovering = false }
vm.clearHoverOpenSuppression()
}
}
private func scheduleHoverOpenIfNeeded() {
hoverTask?.cancel()
guard openNotchOnHover,
vm.notchState == .closed,
!vm.isCloseTransitionActive,
!vm.suppressHoverOpenUntilHoverExit,
vm.isHovering else { return }
hoverTask = Task { @MainActor in
try? await Task.sleep(nanoseconds: UInt64(minimumHoverDuration * 1_000_000_000))
guard !Task.isCancelled,
vm.isHovering,
vm.notchState == .closed,
!vm.isCloseTransitionActive,
!vm.suppressHoverOpenUntilHoverExit else { return }
vm.requestOpen?()
withAnimation(hoverAnimation) {
orchestrator.handleHoverChange(hovering, for: screen.id)
}
}
@@ -251,3 +245,33 @@ private struct ResizeHandleShape: Shape {
return path
}
}
private struct WorkspaceScopedView<Content: View>: View {
@ObservedObject var screen: ScreenContext
@ObservedObject var screenRegistry: ScreenRegistry
let content: (WorkspaceController) -> Content
init(
screen: ScreenContext,
screenRegistry: ScreenRegistry,
@ViewBuilder content: @escaping (WorkspaceController) -> Content
) {
self.screen = screen
self.screenRegistry = screenRegistry
self.content = content
}
var body: some View {
WorkspaceObservedView(workspace: screenRegistry.workspaceController(for: screen.id), content: content)
.id(screen.workspaceID)
}
}
private struct WorkspaceObservedView<Content: View>: View {
@ObservedObject var workspace: WorkspaceController
let content: (WorkspaceController) -> Content
var body: some View {
content(workspace)
}
}

View File

@@ -27,18 +27,16 @@ extension NSScreen {
/// Computes the closed-state notch size for this screen,
/// respecting the user's height mode and custom height preferences.
func closedNotchSize() -> CGSize {
let height = closedNotchHeight()
func closedNotchSize(using settings: AppSettings.DisplaySettings) -> CGSize {
let height = closedNotchHeight(using: settings)
let width = closedNotchWidth()
return CGSize(width: width, height: height)
}
/// Height of the closed notch bar, determined by the user's chosen mode.
private func closedNotchHeight() -> CGFloat {
let defaults = UserDefaults.standard
private func closedNotchHeight(using settings: AppSettings.DisplaySettings) -> CGFloat {
if hasNotch {
let mode = NotchHeightMode(rawValue: defaults.integer(forKey: NotchSettings.Keys.notchHeightMode))
let mode = NotchHeightMode(rawValue: settings.notchHeightMode)
?? .matchRealNotchSize
switch mode {
case .matchRealNotchSize:
@@ -46,16 +44,16 @@ extension NSScreen {
case .matchMenuBar:
return menuBarHeight()
case .custom:
return defaults.double(forKey: NotchSettings.Keys.notchHeight)
return settings.notchHeight
}
} else {
let mode = NonNotchHeightMode(rawValue: defaults.integer(forKey: NotchSettings.Keys.nonNotchHeightMode))
let mode = NonNotchHeightMode(rawValue: settings.nonNotchHeightMode)
?? .matchMenuBar
switch mode {
case .matchMenuBar:
return menuBarHeight()
case .custom:
return defaults.double(forKey: NotchSettings.Keys.nonNotchHeight)
return settings.nonNotchHeight
}
}
}

View File

@@ -1,11 +1,13 @@
import AppKit
import Carbon.HIToolbox
import Combine
/// 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).
@MainActor
class HotkeyManager {
static let shared = HotkeyManager()
@@ -27,37 +29,35 @@ class HotkeyManager {
private var hotKeyRef: EventHotKeyRef?
private var eventHandlerRef: EventHandlerRef?
private var localMonitor: Any?
private var defaultsObserver: NSObjectProtocol?
private let settingsProvider: TerminalSessionConfigurationProviding
private var settingsCancellable: AnyCancellable?
private init() {}
init(settingsProvider: TerminalSessionConfigurationProviding? = nil) {
self.settingsProvider = settingsProvider ?? AppSettingsController.shared
}
// MARK: - Resolved bindings (live from UserDefaults)
// MARK: - Resolved bindings from typed runtime settings
private var toggleBinding: HotkeyBinding {
binding(for: NotchSettings.Keys.hotkeyToggle) ?? .cmdReturn
settingsProvider.hotkeySettings.toggle
}
private var newTabBinding: HotkeyBinding {
binding(for: NotchSettings.Keys.hotkeyNewTab) ?? .cmdT
settingsProvider.hotkeySettings.newTab
}
private var closeTabBinding: HotkeyBinding {
binding(for: NotchSettings.Keys.hotkeyCloseTab) ?? .cmdW
settingsProvider.hotkeySettings.closeTab
}
private var nextTabBinding: HotkeyBinding {
binding(for: NotchSettings.Keys.hotkeyNextTab) ?? .cmdShiftRB
settingsProvider.hotkeySettings.nextTab
}
private var prevTabBinding: HotkeyBinding {
binding(for: NotchSettings.Keys.hotkeyPreviousTab) ?? .cmdShiftLB
settingsProvider.hotkeySettings.previousTab
}
private var detachBinding: HotkeyBinding {
binding(for: NotchSettings.Keys.hotkeyDetachTab) ?? .cmdD
settingsProvider.hotkeySettings.detachTab
}
private var sizePresets: [TerminalSizePreset] {
TerminalSizePresetStore.load()
}
private func binding(for key: String) -> HotkeyBinding? {
guard let json = UserDefaults.standard.string(forKey: key) else { return nil }
return HotkeyBinding.fromJSON(json)
settingsProvider.terminalSizePresets
}
// MARK: - Start / Stop
@@ -73,10 +73,7 @@ class HotkeyManager {
unregisterToggleHotkey()
removeCarbonHandler()
removeLocalMonitor()
if let obs = defaultsObserver {
NotificationCenter.default.removeObserver(obs)
defaultsObserver = nil
}
settingsCancellable = nil
}
// MARK: - Carbon global hotkey (toggle)
@@ -130,7 +127,7 @@ class HotkeyManager {
let binding = toggleBinding
let carbonMods = carbonModifiers(from: binding.modifiers)
var hotKeyID = EventHotKeyID(
let hotKeyID = EventHotKeyID(
signature: OSType(0x444E5452), // "DNTR"
id: 1
)
@@ -163,15 +160,17 @@ class HotkeyManager {
}
}
/// Re-register the toggle hotkey whenever the user changes it in settings.
/// Re-register the toggle hotkey whenever the typed settings change.
private func observeToggleHotkeyChanges() {
defaultsObserver = NotificationCenter.default.addObserver(
forName: UserDefaults.didChangeNotification,
object: nil,
queue: .main
) { [weak self] _ in
self?.registerToggleHotkey()
}
guard let settingsProvider = settingsProvider as? AppSettingsController else { return }
settingsCancellable = settingsProvider.$settings
.map(\.hotkeys.toggle)
.removeDuplicates()
.dropFirst()
.sink { [weak self] _ in
self?.registerToggleHotkey()
}
}
// MARK: - Local monitor (tab-level hotkeys, only when our app is active)

View File

@@ -1,32 +1,29 @@
import AppKit
import SwiftUI
import Combine
import SwiftUI
/// 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.
/// Coordinates screen/workspace state with notch lifecycle and
/// delegates raw window work to `WindowCoordinator`.
@MainActor
class ScreenManager: ObservableObject {
final class ScreenManager: ObservableObject {
static let shared = ScreenManager()
private let focusRetryDelay: TimeInterval = 0.01
private let presetResizeFrameInterval: TimeInterval = 1.0 / 60.0
private(set) var windows: [String: NotchWindow] = [:]
private(set) var viewModels: [String: NotchViewModel] = [:]
private var presetResizeTimers: [String: Timer] = [:]
@AppStorage(NotchSettings.Keys.showOnAllDisplays)
private var showOnAllDisplays = NotchSettings.Defaults.showOnAllDisplays
private let screenRegistry = ScreenRegistry.shared
private let windowCoordinator = WindowCoordinator()
private lazy var orchestrator = NotchOrchestrator(screenRegistry: screenRegistry, host: self)
private var cancellables = Set<AnyCancellable>()
private init() {}
private var showOnAllDisplays: Bool {
AppSettingsController.shared.settings.display.showOnAllDisplays
}
// MARK: - Lifecycle
func start() {
screenRegistry.refreshConnectedScreens()
observeScreenChanges()
rebuildWindows()
setupHotkeys()
@@ -41,94 +38,54 @@ class ScreenManager: ObservableObject {
// MARK: - Hotkey wiring
private func setupHotkeys() {
let hk = HotkeyManager.shared
let tm = TerminalManager.shared
let hotkeyManager = HotkeyManager.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() }
hotkeyManager.onToggle = { [weak self] in
MainActor.assumeIsolated { self?.orchestrator.toggleOnActiveScreen() }
}
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
hotkeyManager.onNewTab = { [weak self] in
MainActor.assumeIsolated { self?.activeWorkspace().newTab() }
}
hotkeyManager.onCloseTab = { [weak self] in
MainActor.assumeIsolated { self?.activeWorkspace().closeActiveTab() }
}
hotkeyManager.onNextTab = { [weak self] in
MainActor.assumeIsolated { self?.activeWorkspace().nextTab() }
}
hotkeyManager.onPreviousTab = { [weak self] in
MainActor.assumeIsolated { self?.activeWorkspace().previousTab() }
}
hotkeyManager.onDetachTab = { [weak self] in
MainActor.assumeIsolated { self?.detachActiveTab() }
}
hk.onApplySizePreset = { [weak self] preset in
hotkeyManager.onApplySizePreset = { [weak self] preset in
MainActor.assumeIsolated { self?.applySizePreset(preset) }
}
hk.onSwitchToTab = { index in
MainActor.assumeIsolated { tm.switchToTab(at: index) }
hotkeyManager.onSwitchToTab = { [weak self] index in
MainActor.assumeIsolated { self?.activeWorkspace().switchToTab(at: index) }
}
hk.start()
hotkeyManager.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)
}
}
orchestrator.toggleOnActiveScreen()
}
// 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 openNotch(screenID: ScreenID) {
orchestrator.open(screenID: screenID)
}
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
func closeNotch(screenID: ScreenID) {
orchestrator.close(screenID: screenID)
}
private func detachActiveTab() {
if let session = TerminalManager.shared.detachActiveTab() {
if let session = activeWorkspace().detachActiveTab() {
DispatchQueue.main.async {
PopoutWindowController.shared.popout(session: session)
}
@@ -136,235 +93,105 @@ class ScreenManager: ObservableObject {
}
func applySizePreset(_ preset: TerminalSizePreset) {
guard let (screenUUID, vm) = viewModels.first(where: { $0.value.notchState == .open }) else {
UserDefaults.standard.set(preset.width, forKey: NotchSettings.Keys.openWidth)
UserDefaults.standard.set(preset.height, forKey: NotchSettings.Keys.openHeight)
guard let context = screenRegistry.allScreens().first(where: { $0.notchState == .open }) else {
AppSettingsController.shared.update {
$0.display.openWidth = preset.width
$0.display.openHeight = preset.height
}
return
}
let startSize = vm.notchSize
let targetSize = vm.setStoredOpenSize(preset.size)
animatePresetResize(for: screenUUID, from: startSize, to: targetSize, duration: vm.openAnimationDuration)
let startSize = context.notchSize
let targetSize = context.setStoredOpenSize(preset.size)
windowCoordinator.animatePresetResize(
for: context.id,
context: context,
from: startSize,
to: targetSize,
duration: context.openAnimationDuration
)
}
// MARK: - Window creation
func rebuildWindows() {
cleanupAllWindows()
screenRegistry.refreshConnectedScreens()
let screens: [NSScreen]
if showOnAllDisplays {
screens = NSScreen.screens
} else {
screens = [NSScreen.main].compactMap { $0 }
}
for screen in screens {
for screen in visibleScreens() {
createWindow(for: screen)
}
}
private func createWindow(for screen: NSScreen) {
let uuid = screen.displayUUID
let vm = NotchViewModel(screenUUID: uuid)
let initialContentSize = vm.openNotchSize
let screenID = screen.displayUUID
guard let context = screenRegistry.screenContext(for: screenID) else { return }
let window = NotchWindow(
contentRect: NSRect(origin: .zero, size: CGSize(width: initialContentSize.width + 40, height: initialContentSize.height + 20)),
styleMask: [.borderless, .nonactivatingPanel, .utilityWindow],
backing: .buffered,
defer: false
)
context.requestOpen = { [weak self] in
self?.orchestrator.open(screenID: screenID)
}
context.requestClose = { [weak self] in
self?.orchestrator.close(screenID: screenID)
}
context.requestWindowResize = { [weak self] in
guard let self,
let context = self.screenRegistry.screenContext(for: screenID) else {
return
}
// Close the notch when the window loses focus
window.onResignKey = { [weak self] in
self?.closeNotch(screenUUID: uuid)
self.windowCoordinator.updateWindowFrame(
for: screenID,
context: context,
centerHorizontally: true
)
}
context.requestTerminalFocus = { [weak self] in
guard let self else { return }
// 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)
}
vm.requestWindowResize = { [weak self] in
self?.updateWindowFrame(for: uuid, centerHorizontally: true)
self.windowCoordinator.focusActiveTerminal(for: screenID) { [weak self] in
self?.screenRegistry.workspaceController(for: screenID).activeTab?.terminalView
}
}
let hostingView = NSHostingView(
rootView: ContentView(vm: vm, terminalManager: TerminalManager.shared)
.preferredColorScheme(.dark)
rootView: ContentView(
screen: context,
orchestrator: orchestrator
)
.preferredColorScheme(.dark)
)
let containerView = NSView(frame: NSRect(origin: .zero, size: window.frame.size))
containerView.autoresizesSubviews = true
containerView.wantsLayer = true
containerView.layer?.backgroundColor = NSColor.clear.cgColor
hostingView.frame = containerView.bounds
hostingView.autoresizingMask = [.width, .height]
containerView.addSubview(hostingView)
window.contentView = containerView
windows[uuid] = window
viewModels[uuid] = vm
updateWindowFrame(for: uuid, centerHorizontally: true)
window.orderFrontRegardless()
windowCoordinator.createWindow(
on: screen,
context: context,
contentView: hostingView,
onResignKey: { [weak self] in
guard !context.suppressCloseOnFocusLoss else { return }
self?.orchestrator.close(screenID: screenID)
}
)
}
// 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 }
screenRegistry.refreshConnectedScreens()
vm.refreshClosedSize()
updateWindowFrame(for: uuid, on: screen, window: window, centerHorizontally: true)
for context in screenRegistry.allScreens() {
context.refreshClosedSize()
windowCoordinator.repositionWindow(
for: context.id,
context: context,
centerHorizontally: true
)
}
}
private func updateWindowFrame(for screenUUID: String, centerHorizontally: Bool = false) {
guard let screen = NSScreen.screens.first(where: { $0.displayUUID == screenUUID }),
let window = windows[screenUUID] else { return }
updateWindowFrame(for: screenUUID, on: screen, window: window, centerHorizontally: centerHorizontally)
}
private func updateWindowFrame(
for screenUUID: String,
on screen: NSScreen,
window: NotchWindow,
centerHorizontally: Bool = false
) {
let frame = targetWindowFrame(
for: screenUUID,
on: screen,
window: window,
centerHorizontally: centerHorizontally,
contentSize: nil
)
guard !window.frame.equalTo(frame) else { return }
window.setFrame(frame, display: false)
}
private func targetWindowFrame(
for screenUUID: String,
on screen: NSScreen,
window: NotchWindow,
centerHorizontally: Bool,
contentSize: CGSize?
) -> NSRect {
guard let vm = viewModels[screenUUID] else { return window.frame }
let shadowPadding: CGFloat = 20
let openSize = contentSize ?? vm.openNotchSize
let windowWidth = openSize.width + 40
let windowHeight = openSize.height + shadowPadding
let centeredX = screen.frame.origin.x + (screen.frame.width - windowWidth) / 2
let x: CGFloat = centerHorizontally || vm.notchState == .closed
? centeredX
: min(max(window.frame.minX, screen.frame.minX), screen.frame.maxX - windowWidth)
return NSRect(
x: x,
y: screen.frame.origin.y + screen.frame.height - windowHeight,
width: windowWidth,
height: windowHeight
)
}
private func animatePresetResize(
for screenUUID: String,
from startSize: CGSize,
to targetSize: CGSize,
duration: TimeInterval
) {
cancelPresetResize(for: screenUUID)
guard let vm = viewModels[screenUUID] else { return }
guard startSize != targetSize else {
vm.notchSize = targetSize
updateWindowFrame(for: screenUUID, centerHorizontally: true)
return
}
vm.isPresetResizing = true
let startTime = CACurrentMediaTime()
let duration = max(duration, presetResizeFrameInterval)
let timer = Timer(timeInterval: presetResizeFrameInterval, repeats: true) { [weak self] timer in
MainActor.assumeIsolated {
guard let self, let vm = self.viewModels[screenUUID] else {
timer.invalidate()
return
}
let elapsed = CACurrentMediaTime() - startTime
let progress = min(1, elapsed / duration)
let easedProgress = 0.5 - (cos(.pi * progress) / 2)
let size = CGSize(
width: startSize.width + ((targetSize.width - startSize.width) * easedProgress),
height: startSize.height + ((targetSize.height - startSize.height) * easedProgress)
)
vm.notchSize = size
self.updateWindowFrame(for: screenUUID, contentSize: size, centerHorizontally: true)
if progress >= 1 {
vm.notchSize = targetSize
vm.isPresetResizing = false
self.updateWindowFrame(for: screenUUID, contentSize: targetSize, centerHorizontally: true)
self.presetResizeTimers[screenUUID] = nil
timer.invalidate()
}
}
}
presetResizeTimers[screenUUID] = timer
RunLoop.main.add(timer, forMode: .common)
timer.fire()
}
private func cancelPresetResize(for screenUUID: String) {
presetResizeTimers[screenUUID]?.invalidate()
presetResizeTimers[screenUUID] = nil
viewModels[screenUUID]?.isPresetResizing = false
}
private func updateWindowFrame(
for screenUUID: String,
contentSize: CGSize,
centerHorizontally: Bool = false
) {
guard let screen = NSScreen.screens.first(where: { $0.displayUUID == screenUUID }),
let window = windows[screenUUID] else { return }
let frame = targetWindowFrame(
for: screenUUID,
on: screen,
window: window,
centerHorizontally: centerHorizontally,
contentSize: contentSize
)
guard !window.frame.equalTo(frame) else { return }
window.setFrame(frame, display: false)
}
// MARK: - Cleanup
private func cleanupAllWindows() {
for (_, timer) in presetResizeTimers {
timer.invalidate()
}
presetResizeTimers.removeAll()
for (_, window) in windows {
window.orderOut(nil)
window.close()
}
windows.removeAll()
viewModels.removeAll()
orchestrator.cancelAllPendingWork()
windowCoordinator.cleanupAllWindows()
}
// MARK: - Screen observation
@@ -372,33 +199,62 @@ class ScreenManager: ObservableObject {
private func observeScreenChanges() {
NotificationCenter.default.publisher(for: NSApplication.didChangeScreenParametersNotification)
.debounce(for: .milliseconds(500), scheduler: RunLoop.main)
.sink { [weak self] _ in self?.handleScreenConfigurationChange() }
.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 {
screenRegistry.refreshConnectedScreens()
let currentScreenIDs = Set(visibleScreens().map(\.displayUUID))
let knownScreenIDs = windowCoordinator.windowScreenIDs()
if currentScreenIDs != knownScreenIDs {
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 }
private func activeWorkspace() -> WorkspaceController {
guard let screenID = screenRegistry.activeScreenID() else {
return WorkspaceRegistry.shared.defaultWorkspaceController
}
if terminalView.window === window {
window.makeFirstResponder(terminalView)
return screenRegistry.workspaceController(for: screenID)
}
private func visibleScreens() -> [NSScreen] {
if showOnAllDisplays {
return NSScreen.screens
}
return [NSScreen.main].compactMap { $0 }
}
}
extension ScreenManager: NotchPresentationHost {
func canPresentNotch(for screenID: ScreenID) -> Bool {
windowCoordinator.hasWindow(for: screenID)
}
func performOpenPresentation(for screenID: ScreenID) {
guard screenRegistry.screenContext(for: screenID) != nil else {
return
}
guard attemptsRemaining > 0 else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + focusRetryDelay) { [weak self] in
self?.focusActiveTerminal(in: screenUUID, attemptsRemaining: attemptsRemaining - 1)
windowCoordinator.presentOpen(for: screenID) { [weak self] in
self?.screenRegistry.workspaceController(for: screenID).activeTab?.terminalView
}
}
func performClosePresentation(for screenID: ScreenID) {
guard screenRegistry.screenContext(for: screenID) != nil else {
return
}
windowCoordinator.presentClose(for: screenID)
}
}

View File

@@ -34,6 +34,7 @@ class SettingsWindowController: NSObject, NSWindowDelegate {
defer: false
)
win.title = "CommandNotch Settings"
win.identifier = NSUserInterfaceItemIdentifier("settings.window")
win.contentView = hostingView
win.center()
win.delegate = self

View File

@@ -0,0 +1,291 @@
import AppKit
import QuartzCore
import SwiftUI
struct WindowFrameCalculator {
static let horizontalPadding: CGFloat = 40
static let verticalPadding: CGFloat = 20
static func targetFrame(
screenFrame: CGRect,
currentWindowFrame: CGRect,
notchState: NotchState,
contentSize: CGSize,
centerHorizontally: Bool
) -> CGRect {
let windowWidth = contentSize.width + horizontalPadding
let windowHeight = contentSize.height + verticalPadding
let centeredX = screenFrame.origin.x + ((screenFrame.width - windowWidth) / 2)
let x: CGFloat
if centerHorizontally || notchState == .closed {
x = centeredX
} else {
x = min(
max(currentWindowFrame.minX, screenFrame.minX),
screenFrame.maxX - windowWidth
)
}
return CGRect(
x: x,
y: screenFrame.origin.y + screenFrame.height - windowHeight,
width: windowWidth,
height: windowHeight
)
}
}
@MainActor
final class WindowCoordinator {
private let focusRetryDelay: TimeInterval
private let presetResizeFrameInterval: TimeInterval
private let screenLookup: @MainActor (ScreenID) -> NSScreen?
private let applicationActivator: @MainActor () -> Void
private let hotkeyOpenStateHandler: @MainActor (Bool) -> Void
private(set) var windows: [ScreenID: NotchWindow] = [:]
private var presetResizeTimers: [ScreenID: Timer] = [:]
init(
focusRetryDelay: TimeInterval = 0.01,
presetResizeFrameInterval: TimeInterval = 1.0 / 60.0,
screenLookup: @escaping @MainActor (ScreenID) -> NSScreen? = { screenID in
NSScreen.screens.first { $0.displayUUID == screenID }
},
applicationActivator: @escaping @MainActor () -> Void = {
NSApp.activate(ignoringOtherApps: true)
},
hotkeyOpenStateHandler: @escaping @MainActor (Bool) -> Void = { isOpen in
HotkeyManager.shared.isNotchOpen = isOpen
}
) {
self.focusRetryDelay = focusRetryDelay
self.presetResizeFrameInterval = presetResizeFrameInterval
self.screenLookup = screenLookup
self.applicationActivator = applicationActivator
self.hotkeyOpenStateHandler = hotkeyOpenStateHandler
}
func hasWindow(for screenID: ScreenID) -> Bool {
windows[screenID] != nil
}
func windowScreenIDs() -> Set<ScreenID> {
Set(windows.keys)
}
func createWindow(
on screen: NSScreen,
context: ScreenContext,
contentView: NSView,
onResignKey: @escaping () -> Void
) {
let initialFrame = WindowFrameCalculator.targetFrame(
screenFrame: screen.frame,
currentWindowFrame: .zero,
notchState: context.notchState,
contentSize: context.openNotchSize,
centerHorizontally: true
)
let window = NotchWindow(
contentRect: initialFrame,
styleMask: [.borderless, .nonactivatingPanel, .utilityWindow],
backing: .buffered,
defer: false
)
window.onResignKey = onResignKey
let containerView = NSView(frame: NSRect(origin: .zero, size: initialFrame.size))
containerView.autoresizesSubviews = true
containerView.wantsLayer = true
containerView.layer?.backgroundColor = NSColor.clear.cgColor
contentView.frame = containerView.bounds
contentView.autoresizingMask = [.width, .height]
containerView.addSubview(contentView)
window.contentView = containerView
windows[context.id] = window
updateWindowFrame(for: context.id, context: context, centerHorizontally: true)
window.orderFrontRegardless()
}
func repositionWindow(for screenID: ScreenID, context: ScreenContext, centerHorizontally: Bool = false) {
updateWindowFrame(for: screenID, context: context, centerHorizontally: centerHorizontally)
}
func updateWindowFrame(
for screenID: ScreenID,
context: ScreenContext,
contentSize: CGSize? = nil,
centerHorizontally: Bool = false
) {
guard let screen = screenLookup(screenID),
let window = windows[screenID] else {
return
}
let frame = WindowFrameCalculator.targetFrame(
screenFrame: screen.frame,
currentWindowFrame: window.frame,
notchState: context.notchState,
contentSize: resolvedContentSize(for: context, override: contentSize),
centerHorizontally: centerHorizontally
)
guard !window.frame.equalTo(frame) else { return }
window.setFrame(frame, display: false)
}
func animatePresetResize(
for screenID: ScreenID,
context: ScreenContext,
from startSize: CGSize,
to targetSize: CGSize,
duration: TimeInterval
) {
cancelPresetResize(for: screenID)
guard startSize != targetSize else {
context.notchSize = targetSize
updateWindowFrame(for: screenID, context: context, contentSize: targetSize, centerHorizontally: true)
return
}
context.isPresetResizing = true
let startTime = CACurrentMediaTime()
let frameInterval = max(duration, presetResizeFrameInterval)
let timer = Timer(timeInterval: presetResizeFrameInterval, repeats: true) { [weak self] timer in
MainActor.assumeIsolated {
guard let self else {
timer.invalidate()
return
}
let elapsed = CACurrentMediaTime() - startTime
let progress = min(1, elapsed / frameInterval)
let easedProgress = 0.5 - (cos(.pi * progress) / 2)
let size = CGSize(
width: startSize.width + ((targetSize.width - startSize.width) * easedProgress),
height: startSize.height + ((targetSize.height - startSize.height) * easedProgress)
)
context.notchSize = size
self.updateWindowFrame(for: screenID, context: context, contentSize: size, centerHorizontally: true)
if progress >= 1 {
context.notchSize = targetSize
context.isPresetResizing = false
self.updateWindowFrame(for: screenID, context: context, contentSize: targetSize, centerHorizontally: true)
self.presetResizeTimers[screenID] = nil
timer.invalidate()
}
}
}
presetResizeTimers[screenID] = timer
RunLoop.main.add(timer, forMode: .common)
timer.fire()
}
func presentOpen(
for screenID: ScreenID,
terminalViewProvider: @escaping @MainActor () -> NSView?
) {
guard let window = windows[screenID] else { return }
window.isNotchOpen = true
updateHotkeyOpenState()
applicationActivator()
window.makeKeyAndOrderFront(nil)
focusActiveTerminal(
in: screenID,
attemptsRemaining: 12,
terminalViewProvider: terminalViewProvider
)
}
func focusActiveTerminal(
for screenID: ScreenID,
terminalViewProvider: @escaping @MainActor () -> NSView?
) {
focusActiveTerminal(
in: screenID,
attemptsRemaining: 12,
terminalViewProvider: terminalViewProvider
)
}
func presentClose(for screenID: ScreenID) {
guard let window = windows[screenID] else { return }
window.isNotchOpen = false
updateHotkeyOpenState()
}
func cleanupAllWindows() {
for timer in presetResizeTimers.values {
timer.invalidate()
}
presetResizeTimers.removeAll()
for window in windows.values {
window.orderOut(nil)
window.close()
}
windows.removeAll()
updateHotkeyOpenState()
}
private func focusActiveTerminal(
in screenID: ScreenID,
attemptsRemaining: Int,
terminalViewProvider: @escaping @MainActor () -> NSView?
) {
guard let window = windows[screenID],
let terminalView = terminalViewProvider() else {
return
}
if terminalView.window === window {
window.makeFirstResponder(terminalView)
return
}
guard attemptsRemaining > 0 else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + focusRetryDelay) { [weak self] in
Task { @MainActor in
self?.focusActiveTerminal(
in: screenID,
attemptsRemaining: attemptsRemaining - 1,
terminalViewProvider: terminalViewProvider
)
}
}
}
private func cancelPresetResize(for screenID: ScreenID) {
presetResizeTimers[screenID]?.invalidate()
presetResizeTimers[screenID] = nil
}
private func resolvedContentSize(for context: ScreenContext, override: CGSize?) -> CGSize {
if let override {
return override
}
return context.notchState == .open ? context.notchSize : context.openNotchSize
}
private func updateHotkeyOpenState() {
hotkeyOpenStateHandler(windows.values.contains(where: \.isNotchOpen))
}
}

View File

@@ -0,0 +1,161 @@
import Foundation
import CoreGraphics
struct AppSettings: Equatable, Codable {
var display: DisplaySettings
var behavior: BehaviorSettings
var appearance: AppearanceSettings
var animation: AnimationSettings
var terminal: TerminalSettings
var hotkeys: HotkeySettings
static let `default` = AppSettings(
display: DisplaySettings(
showOnAllDisplays: NotchSettings.Defaults.showOnAllDisplays,
showMenuBarIcon: NotchSettings.Defaults.showMenuBarIcon,
launchAtLogin: NotchSettings.Defaults.launchAtLogin,
notchHeightMode: NotchSettings.Defaults.notchHeightMode,
notchHeight: NotchSettings.Defaults.notchHeight,
nonNotchHeightMode: NotchSettings.Defaults.nonNotchHeightMode,
nonNotchHeight: NotchSettings.Defaults.nonNotchHeight,
openWidth: NotchSettings.Defaults.openWidth,
openHeight: NotchSettings.Defaults.openHeight
),
behavior: BehaviorSettings(
openNotchOnHover: NotchSettings.Defaults.openNotchOnHover,
minimumHoverDuration: NotchSettings.Defaults.minimumHoverDuration,
enableGestures: NotchSettings.Defaults.enableGestures,
gestureSensitivity: NotchSettings.Defaults.gestureSensitivity
),
appearance: AppearanceSettings(
enableShadow: NotchSettings.Defaults.enableShadow,
shadowRadius: NotchSettings.Defaults.shadowRadius,
shadowOpacity: NotchSettings.Defaults.shadowOpacity,
cornerRadiusScaling: NotchSettings.Defaults.cornerRadiusScaling,
notchOpacity: NotchSettings.Defaults.notchOpacity,
blurRadius: NotchSettings.Defaults.blurRadius
),
animation: AnimationSettings(
openSpringResponse: NotchSettings.Defaults.openSpringResponse,
openSpringDamping: NotchSettings.Defaults.openSpringDamping,
closeSpringResponse: NotchSettings.Defaults.closeSpringResponse,
closeSpringDamping: NotchSettings.Defaults.closeSpringDamping,
hoverSpringResponse: NotchSettings.Defaults.hoverSpringResponse,
hoverSpringDamping: NotchSettings.Defaults.hoverSpringDamping,
resizeAnimationDuration: NotchSettings.Defaults.resizeAnimationDuration
),
terminal: TerminalSettings(
fontSize: NotchSettings.Defaults.terminalFontSize,
shellPath: NotchSettings.Defaults.terminalShell,
themeRawValue: NotchSettings.Defaults.terminalTheme,
sizePresetsJSON: NotchSettings.Defaults.terminalSizePresets
),
hotkeys: HotkeySettings(
toggle: .cmdReturn,
newTab: .cmdT,
closeTab: .cmdW,
nextTab: .cmdShiftRB,
previousTab: .cmdShiftLB,
detachTab: .cmdD
)
)
}
extension AppSettings {
struct DisplaySettings: Equatable, Codable {
var showOnAllDisplays: Bool
var showMenuBarIcon: Bool
var launchAtLogin: Bool
var notchHeightMode: Int
var notchHeight: Double
var nonNotchHeightMode: Int
var nonNotchHeight: Double
var openWidth: Double
var openHeight: Double
}
struct BehaviorSettings: Equatable, Codable {
var openNotchOnHover: Bool
var minimumHoverDuration: Double
var enableGestures: Bool
var gestureSensitivity: Double
}
struct AppearanceSettings: Equatable, Codable {
var enableShadow: Bool
var shadowRadius: Double
var shadowOpacity: Double
var cornerRadiusScaling: Bool
var notchOpacity: Double
var blurRadius: Double
}
struct AnimationSettings: Equatable, Codable {
var openSpringResponse: Double
var openSpringDamping: Double
var closeSpringResponse: Double
var closeSpringDamping: Double
var hoverSpringResponse: Double
var hoverSpringDamping: Double
var resizeAnimationDuration: Double
}
struct TerminalSettings: Equatable, Codable {
var fontSize: Double
var shellPath: String
var themeRawValue: String
var sizePresetsJSON: String
var theme: TerminalTheme {
TerminalTheme.resolve(themeRawValue)
}
var sizePresets: [TerminalSizePreset] {
TerminalSizePresetStore.decodePresets(from: sizePresetsJSON) ?? TerminalSizePresetStore.loadDefaults()
}
}
struct HotkeySettings: Equatable, Codable {
var toggle: HotkeyBinding
var newTab: HotkeyBinding
var closeTab: HotkeyBinding
var nextTab: HotkeyBinding
var previousTab: HotkeyBinding
var detachTab: HotkeyBinding
}
}
extension AppSettings.DisplaySettings {
struct LayoutSignature: Equatable {
var notchHeightMode: Int
var notchHeight: Double
var nonNotchHeightMode: Int
var nonNotchHeight: Double
var openWidth: Double
var openHeight: Double
}
var layoutSignature: LayoutSignature {
LayoutSignature(
notchHeightMode: notchHeightMode,
notchHeight: notchHeight,
nonNotchHeightMode: nonNotchHeightMode,
nonNotchHeight: nonNotchHeight,
openWidth: openWidth,
openHeight: openHeight
)
}
}
struct TerminalSessionConfiguration: Equatable {
var fontSize: CGFloat
var theme: TerminalTheme
var shellPath: String
}
@MainActor
protocol TerminalSessionConfigurationProviding: AnyObject {
var terminalSessionConfiguration: TerminalSessionConfiguration { get }
var hotkeySettings: AppSettings.HotkeySettings { get }
var terminalSizePresets: [TerminalSizePreset] { get }
}

View File

@@ -0,0 +1,74 @@
import Foundation
import Combine
@MainActor
final class AppSettingsController: ObservableObject, TerminalSessionConfigurationProviding {
static let shared = AppSettingsController(
store: UserDefaultsAppSettingsStore(),
observeExternalChanges: true
)
@Published private(set) var settings: AppSettings
private let store: any AppSettingsStoreType
private let notificationCenter: NotificationCenter
private var defaultsObserver: NSObjectProtocol?
init(
store: any AppSettingsStoreType,
observeExternalChanges: Bool = false,
notificationCenter: NotificationCenter = .default
) {
self.store = store
self.notificationCenter = notificationCenter
self.settings = store.load()
if observeExternalChanges {
defaultsObserver = notificationCenter.addObserver(
forName: UserDefaults.didChangeNotification,
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor in
self?.refresh()
}
}
}
}
deinit {
if let defaultsObserver {
notificationCenter.removeObserver(defaultsObserver)
}
}
var terminalSessionConfiguration: TerminalSessionConfiguration {
TerminalSessionConfiguration(
fontSize: CGFloat(settings.terminal.fontSize),
theme: settings.terminal.theme,
shellPath: settings.terminal.shellPath
)
}
var hotkeySettings: AppSettings.HotkeySettings {
settings.hotkeys
}
var terminalSizePresets: [TerminalSizePreset] {
settings.terminal.sizePresets
}
func refresh() {
let loaded = store.load()
guard loaded != settings else { return }
settings = loaded
}
func update(_ mutate: (inout AppSettings) -> Void) {
var updated = settings
mutate(&updated)
guard updated != settings else { return }
settings = updated
store.save(updated)
}
}

View File

@@ -0,0 +1,136 @@
import Foundation
protocol AppSettingsStoreType {
func load() -> AppSettings
func save(_ settings: AppSettings)
}
struct UserDefaultsAppSettingsStore: AppSettingsStoreType {
private let defaults: UserDefaults
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
}
func load() -> AppSettings {
AppSettings(
display: .init(
showOnAllDisplays: bool(NotchSettings.Keys.showOnAllDisplays, default: NotchSettings.Defaults.showOnAllDisplays),
showMenuBarIcon: bool(NotchSettings.Keys.showMenuBarIcon, default: NotchSettings.Defaults.showMenuBarIcon),
launchAtLogin: bool(NotchSettings.Keys.launchAtLogin, default: NotchSettings.Defaults.launchAtLogin),
notchHeightMode: integer(NotchSettings.Keys.notchHeightMode, default: NotchSettings.Defaults.notchHeightMode),
notchHeight: double(NotchSettings.Keys.notchHeight, default: NotchSettings.Defaults.notchHeight),
nonNotchHeightMode: integer(NotchSettings.Keys.nonNotchHeightMode, default: NotchSettings.Defaults.nonNotchHeightMode),
nonNotchHeight: double(NotchSettings.Keys.nonNotchHeight, default: NotchSettings.Defaults.nonNotchHeight),
openWidth: double(NotchSettings.Keys.openWidth, default: NotchSettings.Defaults.openWidth),
openHeight: double(NotchSettings.Keys.openHeight, default: NotchSettings.Defaults.openHeight)
),
behavior: .init(
openNotchOnHover: bool(NotchSettings.Keys.openNotchOnHover, default: NotchSettings.Defaults.openNotchOnHover),
minimumHoverDuration: double(NotchSettings.Keys.minimumHoverDuration, default: NotchSettings.Defaults.minimumHoverDuration),
enableGestures: bool(NotchSettings.Keys.enableGestures, default: NotchSettings.Defaults.enableGestures),
gestureSensitivity: double(NotchSettings.Keys.gestureSensitivity, default: NotchSettings.Defaults.gestureSensitivity)
),
appearance: .init(
enableShadow: bool(NotchSettings.Keys.enableShadow, default: NotchSettings.Defaults.enableShadow),
shadowRadius: double(NotchSettings.Keys.shadowRadius, default: NotchSettings.Defaults.shadowRadius),
shadowOpacity: double(NotchSettings.Keys.shadowOpacity, default: NotchSettings.Defaults.shadowOpacity),
cornerRadiusScaling: bool(NotchSettings.Keys.cornerRadiusScaling, default: NotchSettings.Defaults.cornerRadiusScaling),
notchOpacity: double(NotchSettings.Keys.notchOpacity, default: NotchSettings.Defaults.notchOpacity),
blurRadius: double(NotchSettings.Keys.blurRadius, default: NotchSettings.Defaults.blurRadius)
),
animation: .init(
openSpringResponse: double(NotchSettings.Keys.openSpringResponse, default: NotchSettings.Defaults.openSpringResponse),
openSpringDamping: double(NotchSettings.Keys.openSpringDamping, default: NotchSettings.Defaults.openSpringDamping),
closeSpringResponse: double(NotchSettings.Keys.closeSpringResponse, default: NotchSettings.Defaults.closeSpringResponse),
closeSpringDamping: double(NotchSettings.Keys.closeSpringDamping, default: NotchSettings.Defaults.closeSpringDamping),
hoverSpringResponse: double(NotchSettings.Keys.hoverSpringResponse, default: NotchSettings.Defaults.hoverSpringResponse),
hoverSpringDamping: double(NotchSettings.Keys.hoverSpringDamping, default: NotchSettings.Defaults.hoverSpringDamping),
resizeAnimationDuration: double(NotchSettings.Keys.resizeAnimationDuration, default: NotchSettings.Defaults.resizeAnimationDuration)
),
terminal: .init(
fontSize: double(NotchSettings.Keys.terminalFontSize, default: NotchSettings.Defaults.terminalFontSize),
shellPath: string(NotchSettings.Keys.terminalShell, default: NotchSettings.Defaults.terminalShell),
themeRawValue: string(NotchSettings.Keys.terminalTheme, default: NotchSettings.Defaults.terminalTheme),
sizePresetsJSON: string(NotchSettings.Keys.terminalSizePresets, default: NotchSettings.Defaults.terminalSizePresets)
),
hotkeys: .init(
toggle: hotkey(NotchSettings.Keys.hotkeyToggle, default: .cmdReturn),
newTab: hotkey(NotchSettings.Keys.hotkeyNewTab, default: .cmdT),
closeTab: hotkey(NotchSettings.Keys.hotkeyCloseTab, default: .cmdW),
nextTab: hotkey(NotchSettings.Keys.hotkeyNextTab, default: .cmdShiftRB),
previousTab: hotkey(NotchSettings.Keys.hotkeyPreviousTab, default: .cmdShiftLB),
detachTab: hotkey(NotchSettings.Keys.hotkeyDetachTab, default: .cmdD)
)
)
}
func save(_ settings: AppSettings) {
defaults.set(settings.display.showOnAllDisplays, forKey: NotchSettings.Keys.showOnAllDisplays)
defaults.set(settings.display.showMenuBarIcon, forKey: NotchSettings.Keys.showMenuBarIcon)
defaults.set(settings.display.launchAtLogin, forKey: NotchSettings.Keys.launchAtLogin)
defaults.set(settings.display.notchHeightMode, forKey: NotchSettings.Keys.notchHeightMode)
defaults.set(settings.display.notchHeight, forKey: NotchSettings.Keys.notchHeight)
defaults.set(settings.display.nonNotchHeightMode, forKey: NotchSettings.Keys.nonNotchHeightMode)
defaults.set(settings.display.nonNotchHeight, forKey: NotchSettings.Keys.nonNotchHeight)
defaults.set(settings.display.openWidth, forKey: NotchSettings.Keys.openWidth)
defaults.set(settings.display.openHeight, forKey: NotchSettings.Keys.openHeight)
defaults.set(settings.behavior.openNotchOnHover, forKey: NotchSettings.Keys.openNotchOnHover)
defaults.set(settings.behavior.minimumHoverDuration, forKey: NotchSettings.Keys.minimumHoverDuration)
defaults.set(settings.behavior.enableGestures, forKey: NotchSettings.Keys.enableGestures)
defaults.set(settings.behavior.gestureSensitivity, forKey: NotchSettings.Keys.gestureSensitivity)
defaults.set(settings.appearance.enableShadow, forKey: NotchSettings.Keys.enableShadow)
defaults.set(settings.appearance.shadowRadius, forKey: NotchSettings.Keys.shadowRadius)
defaults.set(settings.appearance.shadowOpacity, forKey: NotchSettings.Keys.shadowOpacity)
defaults.set(settings.appearance.cornerRadiusScaling, forKey: NotchSettings.Keys.cornerRadiusScaling)
defaults.set(settings.appearance.notchOpacity, forKey: NotchSettings.Keys.notchOpacity)
defaults.set(settings.appearance.blurRadius, forKey: NotchSettings.Keys.blurRadius)
defaults.set(settings.animation.openSpringResponse, forKey: NotchSettings.Keys.openSpringResponse)
defaults.set(settings.animation.openSpringDamping, forKey: NotchSettings.Keys.openSpringDamping)
defaults.set(settings.animation.closeSpringResponse, forKey: NotchSettings.Keys.closeSpringResponse)
defaults.set(settings.animation.closeSpringDamping, forKey: NotchSettings.Keys.closeSpringDamping)
defaults.set(settings.animation.hoverSpringResponse, forKey: NotchSettings.Keys.hoverSpringResponse)
defaults.set(settings.animation.hoverSpringDamping, forKey: NotchSettings.Keys.hoverSpringDamping)
defaults.set(settings.animation.resizeAnimationDuration, forKey: NotchSettings.Keys.resizeAnimationDuration)
defaults.set(settings.terminal.fontSize, forKey: NotchSettings.Keys.terminalFontSize)
defaults.set(settings.terminal.shellPath, forKey: NotchSettings.Keys.terminalShell)
defaults.set(settings.terminal.themeRawValue, forKey: NotchSettings.Keys.terminalTheme)
defaults.set(settings.terminal.sizePresetsJSON, forKey: NotchSettings.Keys.terminalSizePresets)
defaults.set(settings.hotkeys.toggle.toJSON(), forKey: NotchSettings.Keys.hotkeyToggle)
defaults.set(settings.hotkeys.newTab.toJSON(), forKey: NotchSettings.Keys.hotkeyNewTab)
defaults.set(settings.hotkeys.closeTab.toJSON(), forKey: NotchSettings.Keys.hotkeyCloseTab)
defaults.set(settings.hotkeys.nextTab.toJSON(), forKey: NotchSettings.Keys.hotkeyNextTab)
defaults.set(settings.hotkeys.previousTab.toJSON(), forKey: NotchSettings.Keys.hotkeyPreviousTab)
defaults.set(settings.hotkeys.detachTab.toJSON(), forKey: NotchSettings.Keys.hotkeyDetachTab)
}
private func bool(_ key: String, default defaultValue: Bool) -> Bool {
guard defaults.object(forKey: key) != nil else { return defaultValue }
return defaults.bool(forKey: key)
}
private func double(_ key: String, default defaultValue: Double) -> Double {
guard defaults.object(forKey: key) != nil else { return defaultValue }
return defaults.double(forKey: key)
}
private func integer(_ key: String, default defaultValue: Int) -> Int {
guard defaults.object(forKey: key) != nil else { return defaultValue }
return defaults.integer(forKey: key)
}
private func string(_ key: String, default defaultValue: String) -> String {
defaults.string(forKey: key) ?? defaultValue
}
private func hotkey(_ key: String, default defaultValue: HotkeyBinding) -> HotkeyBinding {
guard let json = defaults.string(forKey: key),
let binding = HotkeyBinding.fromJSON(json) else { return defaultValue }
return binding
}
}

View File

@@ -0,0 +1,189 @@
import Combine
import SwiftUI
@MainActor
protocol ScreenRegistryType: AnyObject {
func allScreens() -> [ScreenContext]
func screenContext(for id: ScreenID) -> ScreenContext?
func activeScreenID() -> ScreenID?
func presentingScreenID(for workspaceID: WorkspaceID) -> ScreenID?
@discardableResult
func claimWorkspacePresentation(for screenID: ScreenID) -> ScreenID?
func releaseWorkspacePresentation(for screenID: ScreenID)
}
@MainActor
protocol NotchPresentationHost: AnyObject {
func canPresentNotch(for screenID: ScreenID) -> Bool
func performOpenPresentation(for screenID: ScreenID)
func performClosePresentation(for screenID: ScreenID)
}
protocol SchedulerType {
@MainActor
func schedule(after interval: TimeInterval, action: @escaping @MainActor () -> Void) -> AnyCancellable
}
struct TaskScheduler: SchedulerType {
@MainActor
func schedule(after interval: TimeInterval, action: @escaping @MainActor () -> Void) -> AnyCancellable {
let task = Task { @MainActor in
try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000))
guard !Task.isCancelled else { return }
action()
}
return AnyCancellable {
task.cancel()
}
}
}
@MainActor
final class NotchOrchestrator {
private let screenRegistry: any ScreenRegistryType
private weak var host: (any NotchPresentationHost)?
private let settingsController: AppSettingsController
private let scheduler: any SchedulerType
private var hoverOpenTasks: [ScreenID: AnyCancellable] = [:]
private var closeTransitionTasks: [ScreenID: AnyCancellable] = [:]
init(
screenRegistry: any ScreenRegistryType,
host: any NotchPresentationHost,
settingsController: AppSettingsController? = nil,
scheduler: (any SchedulerType)? = nil
) {
self.screenRegistry = screenRegistry
self.host = host
self.settingsController = settingsController ?? AppSettingsController.shared
self.scheduler = scheduler ?? TaskScheduler()
}
func toggleOnActiveScreen() {
guard let screenID = screenRegistry.activeScreenID(),
host?.canPresentNotch(for: screenID) == true,
let context = screenRegistry.screenContext(for: screenID) else {
return
}
if context.notchState == .open {
close(screenID: screenID)
} else {
open(screenID: screenID)
}
}
func open(screenID: ScreenID) {
guard host?.canPresentNotch(for: screenID) == true,
let context = screenRegistry.screenContext(for: screenID) else {
return
}
if let presentingScreenID = screenRegistry.presentingScreenID(for: context.workspaceID),
presentingScreenID != screenID {
close(screenID: presentingScreenID)
}
cancelHoverOpen(for: screenID)
cancelCloseTransition(for: screenID)
context.cancelCloseTransition()
withAnimation(context.openAnimation) {
context.open()
}
_ = screenRegistry.claimWorkspacePresentation(for: screenID)
host?.performOpenPresentation(for: screenID)
}
func close(screenID: ScreenID) {
guard let context = screenRegistry.screenContext(for: screenID) else { return }
cancelHoverOpen(for: screenID)
cancelCloseTransition(for: screenID)
context.beginCloseTransition()
closeTransitionTasks[screenID] = scheduler.schedule(after: context.closeInteractionLockDuration) { [weak self] in
self?.finishCloseTransition(for: screenID)
}
withAnimation(context.closeAnimation) {
context.close()
}
screenRegistry.releaseWorkspacePresentation(for: screenID)
host?.performClosePresentation(for: screenID)
}
func handleHoverChange(_ hovering: Bool, for screenID: ScreenID) {
guard let context = screenRegistry.screenContext(for: screenID) else { return }
context.isHovering = hovering
if hovering {
scheduleHoverOpenIfNeeded(for: screenID)
} else {
cancelHoverOpen(for: screenID)
context.clearHoverOpenSuppression()
}
}
func cancelAllPendingWork() {
for task in hoverOpenTasks.values {
task.cancel()
}
for task in closeTransitionTasks.values {
task.cancel()
}
hoverOpenTasks.removeAll()
closeTransitionTasks.removeAll()
}
private func scheduleHoverOpenIfNeeded(for screenID: ScreenID) {
cancelHoverOpen(for: screenID)
guard let context = screenRegistry.screenContext(for: screenID) else { return }
guard settingsController.settings.behavior.openNotchOnHover,
context.notchState == .closed,
!context.isCloseTransitionActive,
!context.suppressHoverOpenUntilHoverExit,
context.isHovering else {
return
}
hoverOpenTasks[screenID] = scheduler.schedule(after: settingsController.settings.behavior.minimumHoverDuration) { [weak self] in
guard let self,
let context = self.screenRegistry.screenContext(for: screenID),
context.isHovering,
context.notchState == .closed,
!context.isCloseTransitionActive,
!context.suppressHoverOpenUntilHoverExit else {
return
}
self.hoverOpenTasks[screenID] = nil
self.open(screenID: screenID)
}
}
private func finishCloseTransition(for screenID: ScreenID) {
closeTransitionTasks[screenID] = nil
guard let context = screenRegistry.screenContext(for: screenID) else { return }
context.endCloseTransition()
scheduleHoverOpenIfNeeded(for: screenID)
}
private func cancelHoverOpen(for screenID: ScreenID) {
hoverOpenTasks[screenID]?.cancel()
hoverOpenTasks[screenID] = nil
}
private func cancelCloseTransition(for screenID: ScreenID) {
closeTransitionTasks[screenID]?.cancel()
closeTransitionTasks[screenID] = nil
}
}

View File

@@ -48,6 +48,8 @@ enum NotchSettings {
static let terminalShell = "terminalShell"
static let terminalTheme = "terminalTheme"
static let terminalSizePresets = "terminalSizePresets"
static let workspaceSummaries = "workspaceSummaries"
static let screenAssignments = "screenAssignments"
// Hotkeys each stores a HotkeyBinding JSON string
static let hotkeyToggle = "hotkey_toggle"
@@ -212,17 +214,14 @@ enum TerminalSizePresetStore {
static func load() -> [TerminalSizePreset] {
let defaults = UserDefaults.standard
guard let json = defaults.string(forKey: NotchSettings.Keys.terminalSizePresets),
let data = json.data(using: .utf8),
let presets = try? JSONDecoder().decode([TerminalSizePreset].self, from: data) else {
let presets = decodePresets(from: json) else {
return defaultPresets()
}
return presets
}
static func save(_ presets: [TerminalSizePreset]) {
guard let data = try? JSONEncoder().encode(presets),
let json = String(data: data, encoding: .utf8) else { return }
UserDefaults.standard.set(json, forKey: NotchSettings.Keys.terminalSizePresets)
UserDefaults.standard.set(encodePresets(presets), forKey: NotchSettings.Keys.terminalSizePresets)
}
static func reset() {
@@ -234,11 +233,7 @@ enum TerminalSizePresetStore {
}
static func defaultPresetsJSON() -> String {
guard let data = try? JSONEncoder().encode(defaultPresets()),
let json = String(data: data, encoding: .utf8) else {
return "[]"
}
return json
encodePresets(defaultPresets())
}
static func suggestedHotkey(for presets: [TerminalSizePreset]) -> HotkeyBinding? {
@@ -259,4 +254,17 @@ enum TerminalSizePresetStore {
TerminalSizePreset(name: "Large", width: 900, height: 500, hotkey: HotkeyBinding.cmdShiftDigit(3)),
]
}
static func decodePresets(from json: String) -> [TerminalSizePreset]? {
guard let data = json.data(using: .utf8) else { return nil }
return try? JSONDecoder().decode([TerminalSizePreset].self, from: data)
}
static func encodePresets(_ presets: [TerminalSizePreset]) -> String {
guard let data = try? JSONEncoder().encode(presets),
let json = String(data: data, encoding: .utf8) else {
return "[]"
}
return json
}
}

View File

@@ -1,181 +0,0 @@
import SwiftUI
import Combine
/// Per-screen observable state that drives the notch UI.
@MainActor
class NotchViewModel: ObservableObject {
private static let minimumOpenWidth: CGFloat = 320
private static let minimumOpenHeight: CGFloat = 140
private static let windowHorizontalPadding: CGFloat = 40
private static let windowVerticalPadding: CGFloat = 20
let screenUUID: String
@Published var notchState: NotchState = .closed
@Published var notchSize: CGSize
@Published var closedNotchSize: CGSize
@Published var isHovering: Bool = false
@Published var isCloseTransitionActive: Bool = false
@Published var suppressHoverOpenUntilHoverExit: Bool = false
@Published var isUserResizing: Bool = false
@Published var isPresetResizing: Bool = false
let terminalManager = TerminalManager.shared
/// Set by ScreenManager routes open/close through proper
/// window activation so the terminal receives keyboard input.
var requestOpen: (() -> Void)?
var requestClose: (() -> Void)?
var requestWindowResize: (() -> Void)?
private var cancellables = Set<AnyCancellable>()
@AppStorage(NotchSettings.Keys.openWidth) private var openWidth = NotchSettings.Defaults.openWidth
@AppStorage(NotchSettings.Keys.openHeight) private var openHeight = NotchSettings.Defaults.openHeight
@AppStorage(NotchSettings.Keys.openSpringResponse) private var openSpringResponse = NotchSettings.Defaults.openSpringResponse
@AppStorage(NotchSettings.Keys.openSpringDamping) private var openSpringDamping = NotchSettings.Defaults.openSpringDamping
@AppStorage(NotchSettings.Keys.closeSpringResponse) private var closeSpringResponse = NotchSettings.Defaults.closeSpringResponse
@AppStorage(NotchSettings.Keys.closeSpringDamping) private var closeSpringDamping = NotchSettings.Defaults.closeSpringDamping
@AppStorage(NotchSettings.Keys.resizeAnimationDuration) private var resizeAnimationDurationSetting = NotchSettings.Defaults.resizeAnimationDuration
private var closeTransitionTask: Task<Void, Never>?
var openAnimation: Animation {
.spring(response: openSpringResponse, dampingFraction: openSpringDamping)
}
var closeAnimation: Animation {
.spring(response: closeSpringResponse, dampingFraction: closeSpringDamping)
}
var openAnimationDuration: TimeInterval {
max(0.05, resizeAnimationDurationSetting)
}
init(screenUUID: String) {
self.screenUUID = screenUUID
let screen = NSScreen.screens.first { $0.displayUUID == screenUUID } ?? NSScreen.main
let closed = screen?.closedNotchSize() ?? CGSize(width: 220, height: 32)
self.closedNotchSize = closed
self.notchSize = closed
}
func open() {
let size = openNotchSize
openWidth = size.width
openHeight = size.height
notchSize = size
notchState = .open
}
func close() {
refreshClosedSize()
notchSize = closedNotchSize
notchState = .closed
}
func refreshClosedSize() {
let screen = NSScreen.screens.first { $0.displayUUID == screenUUID } ?? NSScreen.main
closedNotchSize = screen?.closedNotchSize() ?? CGSize(width: 220, height: 32)
}
var openNotchSize: CGSize {
clampedOpenSize(CGSize(width: openWidth, height: openHeight))
}
func beginInteractiveResize() {
isUserResizing = true
}
func resizeOpenNotch(to proposedSize: CGSize) {
setOpenSize(proposedSize, notifyWindowResize: true)
}
func endInteractiveResize() {
isUserResizing = false
}
func applySizePreset(_ preset: TerminalSizePreset, notifyWindowResize: Bool = true) {
setOpenSize(preset.size, notifyWindowResize: notifyWindowResize)
}
@discardableResult
func setStoredOpenSize(_ proposedSize: CGSize) -> CGSize {
let clampedSize = clampedOpenSize(proposedSize)
openWidth = clampedSize.width
openHeight = clampedSize.height
return clampedSize
}
@discardableResult
func setOpenSize(_ proposedSize: CGSize, notifyWindowResize: Bool) -> CGSize {
let clampedSize = setStoredOpenSize(proposedSize)
if notchState == .open {
notchSize = clampedSize
}
if notifyWindowResize {
requestWindowResize?()
}
return clampedSize
}
private func clampedOpenSize(_ size: CGSize) -> CGSize {
CGSize(
width: size.width.clamped(to: Self.minimumOpenWidth...maximumAllowedWidth),
height: size.height.clamped(to: Self.minimumOpenHeight...maximumAllowedHeight)
)
}
private var maximumAllowedWidth: CGFloat {
guard let screen = NSScreen.screens.first(where: { $0.displayUUID == screenUUID }) ?? NSScreen.main else {
return Self.minimumOpenWidth
}
return max(Self.minimumOpenWidth, screen.frame.width - Self.windowHorizontalPadding)
}
private var maximumAllowedHeight: CGFloat {
guard let screen = NSScreen.screens.first(where: { $0.displayUUID == screenUUID }) ?? NSScreen.main else {
return Self.minimumOpenHeight
}
return max(Self.minimumOpenHeight, screen.frame.height - Self.windowVerticalPadding)
}
var closeInteractionLockDuration: TimeInterval {
max(closeSpringResponse + 0.2, 0.35)
}
func beginCloseTransition() {
closeTransitionTask?.cancel()
isCloseTransitionActive = true
if isHovering {
suppressHoverOpenUntilHoverExit = true
}
let delay = closeInteractionLockDuration
closeTransitionTask = Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
guard let self, !Task.isCancelled else { return }
self.isCloseTransitionActive = false
self.closeTransitionTask = nil
}
}
func cancelCloseTransition() {
closeTransitionTask?.cancel()
closeTransitionTask = nil
isCloseTransitionActive = false
}
func clearHoverOpenSuppression() {
suppressHoverOpenUntilHoverExit = false
}
deinit {
closeTransitionTask?.cancel()
}
}
private extension CGFloat {
func clamped(to range: ClosedRange<CGFloat>) -> CGFloat {
Swift.min(Swift.max(self, range.lowerBound), range.upperBound)
}
}

View File

@@ -0,0 +1,222 @@
import AppKit
import SwiftUI
typealias ScreenID = String
/// Observable screen-local UI state for one physical display.
@MainActor
final class ScreenContext: ObservableObject, Identifiable {
private static let minimumOpenWidth: CGFloat = 320
private static let minimumOpenHeight: CGFloat = 140
private static let windowHorizontalPadding: CGFloat = 40
private static let windowVerticalPadding: CGFloat = 20
let id: ScreenID
@Published var workspaceID: WorkspaceID
@Published var notchState: NotchState = .closed
@Published var notchSize: CGSize
@Published var closedNotchSize: CGSize
@Published var isHovering = false
@Published var isCloseTransitionActive = false
@Published var suppressHoverOpenUntilHoverExit = false
@Published var isUserResizing = false
@Published var isPresetResizing = false
@Published private(set) var suppressCloseOnFocusLoss = false
var requestOpen: (() -> Void)?
var requestClose: (() -> Void)?
var requestWindowResize: (() -> Void)?
var requestTerminalFocus: (() -> Void)?
private let settingsController: AppSettingsController
private let screenProvider: @MainActor (ScreenID) -> NSScreen?
init(
id: ScreenID,
workspaceID: WorkspaceID,
settingsController: AppSettingsController? = nil,
screenProvider: @escaping @MainActor (ScreenID) -> NSScreen? = { screenID in
NSScreen.screens.first { $0.displayUUID == screenID }
}
) {
self.id = id
self.workspaceID = workspaceID
self.settingsController = settingsController ?? AppSettingsController.shared
self.screenProvider = screenProvider
let closed = Self.resolveClosedNotchSize(
for: id,
using: self.settingsController.settings.display,
screenProvider: screenProvider
)
self.closedNotchSize = closed
self.notchSize = closed
}
var openAnimation: Animation {
let animation = settingsController.settings.animation
return .spring(
response: animation.openSpringResponse,
dampingFraction: animation.openSpringDamping
)
}
var closeAnimation: Animation {
let animation = settingsController.settings.animation
return .spring(
response: animation.closeSpringResponse,
dampingFraction: animation.closeSpringDamping
)
}
var openAnimationDuration: TimeInterval {
max(0.05, settingsController.settings.animation.resizeAnimationDuration)
}
func open() {
notchSize = openNotchSize
notchState = .open
}
func close() {
refreshClosedSize()
notchSize = closedNotchSize
notchState = .closed
}
func updateWorkspace(id: WorkspaceID) {
guard workspaceID != id else { return }
workspaceID = id
}
func refreshClosedSize() {
closedNotchSize = Self.resolveClosedNotchSize(
for: id,
using: settingsController.settings.display,
screenProvider: screenProvider
)
}
var openNotchSize: CGSize {
let display = settingsController.settings.display
return clampedOpenSize(
CGSize(width: display.openWidth, height: display.openHeight)
)
}
func beginInteractiveResize() {
isUserResizing = true
}
func resizeOpenNotch(to proposedSize: CGSize) {
let clampedSize = clampedOpenSize(proposedSize)
if notchState == .open {
notchSize = clampedSize
}
requestWindowResize?()
}
func endInteractiveResize() {
if notchState == .open {
settingsController.update {
$0.display.openWidth = notchSize.width
$0.display.openHeight = notchSize.height
}
}
isUserResizing = false
}
func applySizePreset(_ preset: TerminalSizePreset, notifyWindowResize: Bool = true) {
setOpenSize(preset.size, notifyWindowResize: notifyWindowResize)
}
@discardableResult
func setStoredOpenSize(_ proposedSize: CGSize) -> CGSize {
let clampedSize = clampedOpenSize(proposedSize)
settingsController.update {
$0.display.openWidth = clampedSize.width
$0.display.openHeight = clampedSize.height
}
return clampedSize
}
@discardableResult
func setOpenSize(_ proposedSize: CGSize, notifyWindowResize: Bool) -> CGSize {
let clampedSize = setStoredOpenSize(proposedSize)
if notchState == .open {
notchSize = clampedSize
}
if notifyWindowResize {
requestWindowResize?()
}
return clampedSize
}
private func clampedOpenSize(_ size: CGSize) -> CGSize {
CGSize(
width: size.width.clamped(to: Self.minimumOpenWidth...maximumAllowedWidth),
height: size.height.clamped(to: Self.minimumOpenHeight...maximumAllowedHeight)
)
}
private var maximumAllowedWidth: CGFloat {
guard let screen = resolvedScreen() ?? NSScreen.main else {
return Self.minimumOpenWidth
}
return max(Self.minimumOpenWidth, screen.frame.width - Self.windowHorizontalPadding)
}
private var maximumAllowedHeight: CGFloat {
guard let screen = resolvedScreen() ?? NSScreen.main else {
return Self.minimumOpenHeight
}
return max(Self.minimumOpenHeight, screen.frame.height - Self.windowVerticalPadding)
}
var closeInteractionLockDuration: TimeInterval {
max(settingsController.settings.animation.closeSpringResponse + 0.2, 0.35)
}
func beginCloseTransition() {
isCloseTransitionActive = true
if isHovering {
suppressHoverOpenUntilHoverExit = true
}
}
func cancelCloseTransition() {
isCloseTransitionActive = false
}
func endCloseTransition() {
isCloseTransitionActive = false
}
func clearHoverOpenSuppression() {
suppressHoverOpenUntilHoverExit = false
}
func setCloseOnFocusLossSuppressed(_ suppressed: Bool) {
suppressCloseOnFocusLoss = suppressed
}
private func resolvedScreen() -> NSScreen? {
screenProvider(id)
}
private static func resolveClosedNotchSize(
for screenID: ScreenID,
using settings: AppSettings.DisplaySettings,
screenProvider: @escaping @MainActor (ScreenID) -> NSScreen?
) -> CGSize {
let screen = screenProvider(screenID) ?? NSScreen.main
return screen?.closedNotchSize(using: settings) ?? CGSize(width: 220, height: 32)
}
}
private extension CGFloat {
func clamped(to range: ClosedRange<CGFloat>) -> CGFloat {
Swift.min(Swift.max(self, range.lowerBound), range.upperBound)
}
}

View File

@@ -0,0 +1,268 @@
import AppKit
import Combine
import SwiftUI
struct ConnectedScreenSummary: Identifiable, Equatable {
let id: ScreenID
let displayName: String
let isActive: Bool
let assignedWorkspaceID: WorkspaceID
}
@MainActor
final class ScreenRegistry: ObservableObject {
static let shared = ScreenRegistry(assignmentStore: UserDefaultsScreenAssignmentStore())
@Published private(set) var screenContexts: [ScreenContext] = []
private let workspaceRegistry: WorkspaceRegistry
private let settingsController: AppSettingsController
private let assignmentStore: any ScreenAssignmentStoreType
private let connectedScreenIDsProvider: @MainActor () -> [ScreenID]
private let activeScreenIDProvider: @MainActor () -> ScreenID?
private let screenLookup: @MainActor (ScreenID) -> NSScreen?
private var contextsByID: [ScreenID: ScreenContext] = [:]
private var preferredAssignments: [ScreenID: WorkspaceID]
private var workspacePresenters: [WorkspaceID: ScreenID] = [:]
private var cancellables = Set<AnyCancellable>()
init(
workspaceRegistry: WorkspaceRegistry? = nil,
settingsController: AppSettingsController? = nil,
assignmentStore: (any ScreenAssignmentStoreType)? = nil,
initialAssignments: [ScreenID: WorkspaceID]? = nil,
connectedScreenIDsProvider: @escaping @MainActor () -> [ScreenID] = {
NSScreen.screens.map(\.displayUUID)
},
activeScreenIDProvider: @escaping @MainActor () -> ScreenID? = {
let mouseLocation = NSEvent.mouseLocation
return NSScreen.screens.first { NSMouseInRect(mouseLocation, $0.frame, false) }?.displayUUID
?? NSScreen.main?.displayUUID
},
screenLookup: @escaping @MainActor (ScreenID) -> NSScreen? = { screenID in
NSScreen.screens.first { $0.displayUUID == screenID }
}
) {
let resolvedWorkspaceRegistry = workspaceRegistry ?? WorkspaceRegistry.shared
let resolvedSettingsController = settingsController ?? AppSettingsController.shared
let resolvedAssignmentStore = assignmentStore ?? UserDefaultsScreenAssignmentStore()
self.workspaceRegistry = resolvedWorkspaceRegistry
self.settingsController = resolvedSettingsController
self.assignmentStore = resolvedAssignmentStore
self.preferredAssignments = initialAssignments ?? resolvedAssignmentStore.loadScreenAssignments()
self.connectedScreenIDsProvider = connectedScreenIDsProvider
self.activeScreenIDProvider = activeScreenIDProvider
self.screenLookup = screenLookup
observeWorkspaceChanges()
refreshConnectedScreens()
}
func allScreens() -> [ScreenContext] {
screenContexts
}
func screenContext(for id: ScreenID) -> ScreenContext? {
contextsByID[id]
}
func workspaceController(for screenID: ScreenID) -> WorkspaceController {
let workspaceID = contextsByID[screenID]?.workspaceID ?? workspaceRegistry.defaultWorkspaceID
return workspaceRegistry.controller(for: workspaceID) ?? workspaceRegistry.defaultWorkspaceController
}
func assignedScreenIDs(to workspaceID: WorkspaceID) -> [ScreenID] {
preferredAssignments
.filter { $0.value == workspaceID }
.map(\.key)
.sorted()
}
func assignedScreenCount(to workspaceID: WorkspaceID) -> Int {
assignedScreenIDs(to: workspaceID).count
}
func connectedScreenSummaries() -> [ConnectedScreenSummary] {
let activeScreenID = activeScreenID()
return screenContexts.enumerated().map { index, context in
ConnectedScreenSummary(
id: context.id,
displayName: resolvedDisplayName(for: context.id, fallbackIndex: index),
isActive: context.id == activeScreenID,
assignedWorkspaceID: context.workspaceID
)
}
}
func assignWorkspace(_ workspaceID: WorkspaceID, to screenID: ScreenID) {
guard workspaceRegistry.controller(for: workspaceID) != nil else { return }
let previousWorkspaceID = contextsByID[screenID]?.workspaceID ?? preferredAssignments[screenID]
preferredAssignments[screenID] = workspaceID
contextsByID[screenID]?.updateWorkspace(id: workspaceID)
if let previousWorkspaceID,
previousWorkspaceID != workspaceID,
workspacePresenters[previousWorkspaceID] == screenID {
workspacePresenters.removeValue(forKey: previousWorkspaceID)
}
persistAssignments()
}
@discardableResult
func assignActiveScreen(to workspaceID: WorkspaceID) -> ScreenID? {
guard let screenID = activeScreenID() else { return nil }
assignWorkspace(workspaceID, to: screenID)
return screenID
}
func presentingScreenID(for workspaceID: WorkspaceID) -> ScreenID? {
guard let screenID = workspacePresenters[workspaceID] else { return nil }
guard preferredAssignments[screenID] == workspaceID else {
workspacePresenters.removeValue(forKey: workspaceID)
return nil
}
return screenID
}
@discardableResult
func claimWorkspacePresentation(for screenID: ScreenID) -> ScreenID? {
guard let workspaceID = contextsByID[screenID]?.workspaceID ?? preferredAssignments[screenID] else {
return nil
}
let previousPresenter = workspacePresenters[workspaceID]
workspacePresenters[workspaceID] = screenID
return previousPresenter == screenID ? nil : previousPresenter
}
func releaseWorkspacePresentation(for screenID: ScreenID) {
workspacePresenters = workspacePresenters.filter { $0.value != screenID }
}
@discardableResult
func deleteWorkspace(
_ workspaceID: WorkspaceID,
preferredFallback preferredFallbackID: WorkspaceID? = nil
) -> WorkspaceID? {
guard workspaceRegistry.canDeleteWorkspace(id: workspaceID) else {
return nil
}
guard let fallbackWorkspaceID = workspaceRegistry.deletionFallbackWorkspaceID(
forDeleting: workspaceID,
preferredFallback: preferredFallbackID
) else {
return nil
}
workspacePresenters.removeValue(forKey: workspaceID)
for (screenID, assignedWorkspaceID) in preferredAssignments where assignedWorkspaceID == workspaceID {
preferredAssignments[screenID] = fallbackWorkspaceID
}
for context in contextsByID.values where context.workspaceID == workspaceID {
context.updateWorkspace(id: fallbackWorkspaceID)
}
guard workspaceRegistry.deleteWorkspace(id: workspaceID) else {
return nil
}
persistAssignments()
return fallbackWorkspaceID
}
func activeScreenID() -> ScreenID? {
activeScreenIDProvider() ?? screenContexts.first?.id
}
func refreshConnectedScreens() {
let connectedScreenIDs = connectedScreenIDsProvider()
let validWorkspaceIDs = Set(workspaceRegistry.allWorkspaceSummaries().map(\.id))
let defaultWorkspaceID = workspaceRegistry.defaultWorkspaceID
var nextContextsByID: [ScreenID: ScreenContext] = [:]
var nextContexts: [ScreenContext] = []
for screenID in connectedScreenIDs {
let workspaceID = resolvedWorkspaceID(
for: screenID,
validWorkspaceIDs: validWorkspaceIDs,
defaultWorkspaceID: defaultWorkspaceID
)
let context = contextsByID[screenID] ?? ScreenContext(
id: screenID,
workspaceID: workspaceID,
settingsController: settingsController,
screenProvider: screenLookup
)
context.updateWorkspace(id: workspaceID)
context.refreshClosedSize()
nextContextsByID[screenID] = context
nextContexts.append(context)
}
contextsByID = nextContextsByID
screenContexts = nextContexts
reconcileWorkspacePresenters()
persistAssignments()
}
private func resolvedWorkspaceID(
for screenID: ScreenID,
validWorkspaceIDs: Set<WorkspaceID>,
defaultWorkspaceID: WorkspaceID
) -> WorkspaceID {
guard let preferredWorkspaceID = preferredAssignments[screenID],
validWorkspaceIDs.contains(preferredWorkspaceID) else {
preferredAssignments[screenID] = defaultWorkspaceID
return defaultWorkspaceID
}
return preferredWorkspaceID
}
private func observeWorkspaceChanges() {
workspaceRegistry.$workspaceSummaries
.dropFirst()
.sink { [weak self] _ in
Task { @MainActor [weak self] in
self?.refreshConnectedScreens()
}
}
.store(in: &cancellables)
}
private func persistAssignments() {
assignmentStore.saveScreenAssignments(preferredAssignments)
}
private func reconcileWorkspacePresenters() {
let validScreenIDs = Set(contextsByID.keys)
let validAssignments = preferredAssignments
workspacePresenters = workspacePresenters.filter { workspaceID, screenID in
validScreenIDs.contains(screenID) && validAssignments[screenID] == workspaceID
}
}
private func resolvedDisplayName(for screenID: ScreenID, fallbackIndex: Int) -> String {
let fallbackName = "Screen \(fallbackIndex + 1)"
guard let screen = screenLookup(screenID) else {
return fallbackName
}
let localizedName = screen.localizedName.trimmingCharacters(in: .whitespacesAndNewlines)
return localizedName.isEmpty ? fallbackName : localizedName
}
}
extension ScreenRegistry: ScreenRegistryType {}

View File

@@ -1,118 +1,75 @@
import SwiftUI
import Combine
/// Manages multiple terminal tabs. Singleton shared across all screens
/// whichever notch is currently open displays these tabs.
/// Compatibility adapter for the legacy single-workspace architecture.
/// New code should use `WorkspaceRegistry` + `WorkspaceController`.
@MainActor
class TerminalManager: ObservableObject {
static let shared = TerminalManager()
@Published var tabs: [TerminalSession] = []
@Published var activeTabIndex: Int = 0
@AppStorage(NotchSettings.Keys.terminalFontSize)
private var fontSize: Double = NotchSettings.Defaults.terminalFontSize
@AppStorage(NotchSettings.Keys.terminalTheme)
private var theme: String = NotchSettings.Defaults.terminalTheme
private var cancellables = Set<AnyCancellable>()
private var workspaceCancellable: AnyCancellable?
private init() {
newTab()
workspaceCancellable = WorkspaceRegistry.shared.defaultWorkspaceController.objectWillChange
.sink { [weak self] _ in
self?.objectWillChange.send()
}
}
// MARK: - Active tab
private var workspace: WorkspaceController {
WorkspaceRegistry.shared.defaultWorkspaceController
}
var tabs: [TerminalSession] {
workspace.tabs
}
var activeTabIndex: Int {
workspace.activeTabIndex
}
var activeTab: TerminalSession? {
guard tabs.indices.contains(activeTabIndex) else { return nil }
return tabs[activeTabIndex]
workspace.activeTab
}
/// Short title for the closed notch bar the active tab's process name.
var activeTitle: String {
activeTab?.title ?? "shell"
workspace.activeTitle
}
// MARK: - Tab operations
func newTab() {
let session = TerminalSession(
fontSize: CGFloat(fontSize),
theme: TerminalTheme.resolve(theme)
)
// Forward title changes to trigger view updates in this manager
session.$title
.receive(on: RunLoop.main)
.sink { [weak self] _ in self?.objectWillChange.send() }
.store(in: &cancellables)
tabs.append(session)
activeTabIndex = tabs.count - 1
}
func closeTab(at index: Int) {
guard tabs.indices.contains(index) else { return }
tabs[index].terminate()
tabs.remove(at: index)
// Adjust active index
if tabs.isEmpty {
newTab()
} else if activeTabIndex >= tabs.count {
activeTabIndex = tabs.count - 1
}
workspace.newTab()
}
func closeActiveTab() {
closeTab(at: activeTabIndex)
workspace.closeActiveTab()
}
func closeTab(at index: Int) {
workspace.closeTab(at: index)
}
func switchToTab(at index: Int) {
guard tabs.indices.contains(index) else { return }
activeTabIndex = index
workspace.switchToTab(at: index)
}
func nextTab() {
guard tabs.count > 1 else { return }
activeTabIndex = (activeTabIndex + 1) % tabs.count
workspace.nextTab()
}
func previousTab() {
guard tabs.count > 1 else { return }
activeTabIndex = (activeTabIndex - 1 + tabs.count) % tabs.count
}
/// Removes the tab at the given index and returns the session so it
/// can be hosted in a pop-out window.
func detachTab(at index: Int) -> TerminalSession? {
guard tabs.indices.contains(index) else { return nil }
let session = tabs.remove(at: index)
if tabs.isEmpty {
newTab()
} else if activeTabIndex >= tabs.count {
activeTabIndex = tabs.count - 1
}
return session
workspace.previousTab()
}
func detachActiveTab() -> TerminalSession? {
detachTab(at: activeTabIndex)
workspace.detachActiveTab()
}
/// Updates font size on all existing terminal sessions.
func updateAllFontSizes(_ size: CGFloat) {
for tab in tabs {
tab.updateFontSize(size)
}
workspace.updateAllFontSizes(size)
}
func updateAllThemes(_ theme: TerminalTheme) {
for tab in tabs {
tab.applyTheme(theme)
}
workspace.updateAllThemes(theme)
}
}

View File

@@ -4,19 +4,21 @@ import Combine
/// Wraps a single SwiftTerm TerminalView + LocalProcess pair.
@MainActor
class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, TerminalViewDelegate {
class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, @preconcurrency TerminalViewDelegate {
let id = UUID()
let terminalView: TerminalView
private var process: LocalProcess?
private let backgroundColor = NSColor.black
private let configuredShellPath: String
@Published var title: String = "shell"
@Published var isRunning: Bool = true
@Published var currentDirectory: String?
init(fontSize: CGFloat, theme: TerminalTheme) {
init(fontSize: CGFloat, theme: TerminalTheme, shellPath: String) {
terminalView = TerminalView(frame: NSRect(x: 0, y: 0, width: 600, height: 300))
configuredShellPath = shellPath
super.init()
terminalView.terminalDelegate = self
@@ -35,21 +37,21 @@ class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, Termina
let shellName = (shellPath as NSString).lastPathComponent
let loginExecName = "-\(shellName)"
FileManager.default.changeCurrentDirectoryPath(NSHomeDirectory())
let proc = LocalProcess(delegate: self)
// Launch as a login shell so user startup files initialize PATH/tools.
proc.startProcess(
executable: shellPath,
args: ["-l"],
environment: nil,
execName: loginExecName
execName: loginExecName,
currentDirectory: NSHomeDirectory()
)
process = proc
title = shellName
}
private func resolveShell() -> String {
let custom = UserDefaults.standard.string(forKey: NotchSettings.Keys.terminalShell) ?? ""
let custom = configuredShellPath.trimmingCharacters(in: .whitespacesAndNewlines)
if !custom.isEmpty && FileManager.default.isExecutableFile(atPath: custom) {
return custom
}

View File

@@ -0,0 +1,167 @@
import SwiftUI
import Combine
@MainActor
protocol TerminalSessionFactoryType {
func makeSession(fontSize: CGFloat, theme: TerminalTheme, shellPath: String) -> TerminalSession
}
struct LiveTerminalSessionFactory: TerminalSessionFactoryType {
func makeSession(fontSize: CGFloat, theme: TerminalTheme, shellPath: String) -> TerminalSession {
TerminalSession(fontSize: fontSize, theme: theme, shellPath: shellPath)
}
}
@MainActor
final class WorkspaceController: ObservableObject {
let id: WorkspaceID
let createdAt: Date
@Published private(set) var name: String
@Published private(set) var tabs: [TerminalSession] = []
@Published private(set) var activeTabIndex: Int = 0
private let sessionFactory: TerminalSessionFactoryType
private let settingsProvider: TerminalSessionConfigurationProviding
private var titleObservers: [UUID: AnyCancellable] = [:]
init(
summary: WorkspaceSummary,
sessionFactory: TerminalSessionFactoryType,
settingsProvider: TerminalSessionConfigurationProviding,
bootstrapDefaultTab: Bool = true
) {
self.id = summary.id
self.name = summary.name
self.createdAt = summary.createdAt
self.sessionFactory = sessionFactory
self.settingsProvider = settingsProvider
if bootstrapDefaultTab {
newTab()
}
}
convenience init(summary: WorkspaceSummary) {
self.init(
summary: summary,
sessionFactory: LiveTerminalSessionFactory(),
settingsProvider: AppSettingsController.shared
)
}
var summary: WorkspaceSummary {
WorkspaceSummary(id: id, name: name, createdAt: createdAt)
}
var state: WorkspaceState {
WorkspaceState(
id: id,
name: name,
tabs: tabs.map { WorkspaceTabState(id: $0.id, title: $0.title) },
activeTabID: activeTab?.id
)
}
var activeTab: TerminalSession? {
guard tabs.indices.contains(activeTabIndex) else { return nil }
return tabs[activeTabIndex]
}
var activeTitle: String {
activeTab?.title ?? "shell"
}
func rename(to updatedName: String) {
let trimmed = updatedName.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, trimmed != name else { return }
name = trimmed
}
func newTab() {
let config = settingsProvider.terminalSessionConfiguration
let session = sessionFactory.makeSession(
fontSize: config.fontSize,
theme: config.theme,
shellPath: config.shellPath
)
titleObservers[session.id] = session.$title
.receive(on: RunLoop.main)
.sink { [weak self] _ in
self?.objectWillChange.send()
}
tabs.append(session)
activeTabIndex = tabs.count - 1
}
func closeTab(at index: Int) {
guard tabs.indices.contains(index) else { return }
let session = tabs.remove(at: index)
titleObservers.removeValue(forKey: session.id)
session.terminate()
if tabs.isEmpty {
newTab()
} else if activeTabIndex >= tabs.count {
activeTabIndex = tabs.count - 1
}
}
func closeActiveTab() {
closeTab(at: activeTabIndex)
}
func switchToTab(at index: Int) {
guard tabs.indices.contains(index) else { return }
activeTabIndex = index
}
func switchToTab(id: UUID) {
guard let index = tabs.firstIndex(where: { $0.id == id }) else { return }
activeTabIndex = index
}
func nextTab() {
guard tabs.count > 1 else { return }
activeTabIndex = (activeTabIndex + 1) % tabs.count
}
func previousTab() {
guard tabs.count > 1 else { return }
activeTabIndex = (activeTabIndex - 1 + tabs.count) % tabs.count
}
func detachTab(at index: Int) -> TerminalSession? {
guard tabs.indices.contains(index) else { return nil }
let session = tabs.remove(at: index)
titleObservers.removeValue(forKey: session.id)
if tabs.isEmpty {
newTab()
} else if activeTabIndex >= tabs.count {
activeTabIndex = tabs.count - 1
}
return session
}
func detachActiveTab() -> TerminalSession? {
detachTab(at: activeTabIndex)
}
func updateAllFontSizes(_ size: CGFloat) {
for tab in tabs {
tab.updateFontSize(size)
}
}
func updateAllThemes(_ theme: TerminalTheme) {
for tab in tabs {
tab.applyTheme(theme)
}
}
}

View File

@@ -0,0 +1,150 @@
import SwiftUI
@MainActor
final class WorkspaceRegistry: ObservableObject {
static let shared = WorkspaceRegistry(store: UserDefaultsWorkspaceStore())
@Published private(set) var workspaceSummaries: [WorkspaceSummary]
private let store: any WorkspaceStoreType
private var controllers: [WorkspaceID: WorkspaceController] = [:]
private let controllerFactory: @MainActor (WorkspaceSummary) -> WorkspaceController
init(
initialWorkspaces: [WorkspaceSummary]? = nil,
store: (any WorkspaceStoreType)? = nil,
controllerFactory: @escaping @MainActor (WorkspaceSummary) -> WorkspaceController = { summary in
WorkspaceController(summary: summary)
}
) {
let resolvedStore = store ?? UserDefaultsWorkspaceStore()
let resolvedWorkspaces = initialWorkspaces ?? resolvedStore.loadWorkspaceSummaries()
self.store = resolvedStore
self.controllerFactory = controllerFactory
self.workspaceSummaries = resolvedWorkspaces
for summary in resolvedWorkspaces {
controllers[summary.id] = controllerFactory(summary)
}
_ = ensureWorkspaceExists()
}
var defaultWorkspaceID: WorkspaceID {
ensureWorkspaceExists()
}
var defaultWorkspaceController: WorkspaceController {
let workspaceID = ensureWorkspaceExists()
guard let controller = controllers[workspaceID] else {
let summary = WorkspaceSummary(id: workspaceID, name: "Main")
let controller = controllerFactory(summary)
controllers[workspaceID] = controller
return controller
}
return controller
}
func allWorkspaceSummaries() -> [WorkspaceSummary] {
workspaceSummaries
}
func summary(for id: WorkspaceID) -> WorkspaceSummary? {
workspaceSummaries.first { $0.id == id }
}
func controller(for id: WorkspaceID) -> WorkspaceController? {
controllers[id]
}
func canDeleteWorkspace(id: WorkspaceID) -> Bool {
workspaceSummaries.count > 1 && workspaceSummaries.contains { $0.id == id }
}
func deletionFallbackWorkspaceID(
forDeleting id: WorkspaceID,
preferredFallback preferredFallbackID: WorkspaceID? = nil
) -> WorkspaceID? {
let candidates = workspaceSummaries.filter { $0.id != id }
if let preferredFallbackID,
candidates.contains(where: { $0.id == preferredFallbackID }) {
return preferredFallbackID
}
return candidates.first?.id
}
@discardableResult
func ensureWorkspaceExists() -> WorkspaceID {
if let existing = workspaceSummaries.first {
return existing.id
}
return createWorkspace(named: "Main")
}
@discardableResult
func createWorkspace(named name: String? = nil) -> WorkspaceID {
let workspaceName = resolvedWorkspaceName(from: name)
let summary = WorkspaceSummary(name: workspaceName)
workspaceSummaries.append(summary)
controllers[summary.id] = controllerFactory(summary)
persistWorkspaceSummaries()
return summary.id
}
func renameWorkspace(id: WorkspaceID, to name: String) {
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
guard let index = workspaceSummaries.firstIndex(where: { $0.id == id }) else { return }
workspaceSummaries[index].name = trimmed
controllers[id]?.rename(to: trimmed)
persistWorkspaceSummaries()
}
@discardableResult
func deleteWorkspace(id: WorkspaceID) -> Bool {
guard canDeleteWorkspace(id: id) else { return false }
workspaceSummaries.removeAll { $0.id == id }
controllers.removeValue(forKey: id)
_ = ensureWorkspaceExists()
persistWorkspaceSummaries()
return true
}
func updateAllWorkspacesFontSizes(_ size: CGFloat) {
for controller in controllers.values {
controller.updateAllFontSizes(size)
}
}
func updateAllWorkspacesThemes(_ theme: TerminalTheme) {
for controller in controllers.values {
controller.updateAllThemes(theme)
}
}
private func resolvedWorkspaceName(from proposedName: String?) -> String {
let trimmed = proposedName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmed.isEmpty {
return trimmed
}
let existing = Set(workspaceSummaries.map(\.name))
if !existing.contains("Main") {
return "Main"
}
var index = 2
while existing.contains("Workspace \(index)") {
index += 1
}
return "Workspace \(index)"
}
private func persistWorkspaceSummaries() {
store.saveWorkspaceSummaries(workspaceSummaries)
}
}

View File

@@ -0,0 +1,59 @@
import Foundation
protocol WorkspaceStoreType {
func loadWorkspaceSummaries() -> [WorkspaceSummary]
func saveWorkspaceSummaries(_ summaries: [WorkspaceSummary])
}
protocol ScreenAssignmentStoreType {
func loadScreenAssignments() -> [ScreenID: WorkspaceID]
func saveScreenAssignments(_ assignments: [ScreenID: WorkspaceID])
}
struct UserDefaultsWorkspaceStore: WorkspaceStoreType {
private let defaults: UserDefaults
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
}
func loadWorkspaceSummaries() -> [WorkspaceSummary] {
guard let data = defaults.data(forKey: NotchSettings.Keys.workspaceSummaries),
let summaries = try? decoder.decode([WorkspaceSummary].self, from: data) else {
return []
}
return summaries
}
func saveWorkspaceSummaries(_ summaries: [WorkspaceSummary]) {
guard let data = try? encoder.encode(summaries) else { return }
defaults.set(data, forKey: NotchSettings.Keys.workspaceSummaries)
}
}
struct UserDefaultsScreenAssignmentStore: ScreenAssignmentStoreType {
private let defaults: UserDefaults
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
}
func loadScreenAssignments() -> [ScreenID: WorkspaceID] {
guard let data = defaults.data(forKey: NotchSettings.Keys.screenAssignments),
let assignments = try? decoder.decode([ScreenID: WorkspaceID].self, from: data) else {
return [:]
}
return assignments
}
func saveScreenAssignments(_ assignments: [ScreenID: WorkspaceID]) {
guard let data = try? encoder.encode(assignments) else { return }
defaults.set(data, forKey: NotchSettings.Keys.screenAssignments)
}
}

View File

@@ -0,0 +1,27 @@
import Foundation
typealias WorkspaceID = UUID
struct WorkspaceSummary: Identifiable, Equatable, Codable {
var id: WorkspaceID
var name: String
var createdAt: Date
init(id: WorkspaceID = UUID(), name: String, createdAt: Date = Date()) {
self.id = id
self.name = name
self.createdAt = createdAt
}
}
struct WorkspaceTabState: Identifiable, Equatable {
var id: UUID
var title: String
}
struct WorkspaceState: Equatable {
var id: WorkspaceID
var name: String
var tabs: [WorkspaceTabState]
var activeTabID: UUID?
}

View File

@@ -0,0 +1,29 @@
import SwiftUI
struct AboutSettingsView: View {
private var versionLabel: String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"
}
var body: some View {
VStack(spacing: 16) {
Image(systemName: "terminal")
.font(.system(size: 64))
.foregroundStyle(.secondary)
Text("CommandNotch")
.font(.largeTitle.bold())
Text("Version \(versionLabel)")
.foregroundStyle(.secondary)
Text("A drop-down terminal that lives in your notch.")
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
Spacer()
}
.frame(maxWidth: .infinity)
.padding(.top, 40)
}
}

View File

@@ -0,0 +1,72 @@
import SwiftUI
struct AnimationSettingsView: View {
@ObservedObject private var settingsController = AppSettingsController.shared
var body: some View {
Form {
Section("Open Animation") {
springControls(
response: settingsController.binding(\.animation.openSpringResponse),
damping: settingsController.binding(\.animation.openSpringDamping)
)
}
Section("Close Animation") {
springControls(
response: settingsController.binding(\.animation.closeSpringResponse),
damping: settingsController.binding(\.animation.closeSpringDamping)
)
}
Section("Hover Animation") {
springControls(
response: settingsController.binding(\.animation.hoverSpringResponse),
damping: settingsController.binding(\.animation.hoverSpringDamping)
)
}
Section("Resize Animation") {
durationControl(duration: settingsController.binding(\.animation.resizeAnimationDuration))
}
Section {
Button("Reset to Defaults") {
settingsController.update {
$0.animation = AppSettings.default.animation
}
}
}
}
.formStyle(.grouped)
}
@ViewBuilder
private func springControls(response: Binding<Double>, damping: Binding<Double>) -> some View {
HStack {
Text("Response")
Slider(value: response, in: 0.1...1.5, step: 0.01)
Text(String(format: "%.2f", response.wrappedValue))
.monospacedDigit()
.frame(width: 50)
}
HStack {
Text("Damping")
Slider(value: damping, in: 0.1...1.5, step: 0.01)
Text(String(format: "%.2f", damping.wrappedValue))
.monospacedDigit()
.frame(width: 50)
}
}
@ViewBuilder
private func durationControl(duration: Binding<Double>) -> some View {
HStack {
Text("Duration")
Slider(value: duration, in: 0.05...1.5, step: 0.01)
Text(String(format: "%.2fs", duration.wrappedValue))
.monospacedDigit()
.frame(width: 56)
}
}
}

View File

@@ -0,0 +1,51 @@
import SwiftUI
struct AppearanceSettingsView: View {
@ObservedObject private var settingsController = AppSettingsController.shared
var body: some View {
Form {
Section("Shadow") {
Toggle("Enable shadow", isOn: settingsController.binding(\.appearance.enableShadow))
if settingsController.settings.appearance.enableShadow {
HStack {
Text("Radius")
Slider(value: settingsController.binding(\.appearance.shadowRadius), in: 0...30, step: 1)
Text(String(format: "%.0f", settingsController.settings.appearance.shadowRadius))
.monospacedDigit()
.frame(width: 40)
}
HStack {
Text("Opacity")
Slider(value: settingsController.binding(\.appearance.shadowOpacity), in: 0...1, step: 0.05)
Text(String(format: "%.2f", settingsController.settings.appearance.shadowOpacity))
.monospacedDigit()
.frame(width: 50)
}
}
}
Section("Shape") {
Toggle("Scale corner radii when open", isOn: settingsController.binding(\.appearance.cornerRadiusScaling))
}
Section("Opacity & Blur") {
HStack {
Text("Notch opacity")
Slider(value: settingsController.binding(\.appearance.notchOpacity), in: 0...1, step: 0.05)
Text(String(format: "%.2f", settingsController.settings.appearance.notchOpacity))
.monospacedDigit()
.frame(width: 50)
}
HStack {
Text("Blur radius")
Slider(value: settingsController.binding(\.appearance.blurRadius), in: 0...20, step: 0.5)
Text(String(format: "%.1f", settingsController.settings.appearance.blurRadius))
.monospacedDigit()
.frame(width: 50)
}
}
}
.formStyle(.grouped)
}
}

View File

@@ -0,0 +1,107 @@
import AppKit
import SwiftUI
struct GeneralSettingsView: View {
@ObservedObject private var settingsController = AppSettingsController.shared
private var maxOpenWidth: Double {
let currentWidth = settingsController.settings.display.openWidth
let screenWidth = NSScreen.screens.map { $0.frame.width - 40 }.max() ?? 1600
return max(currentWidth, Double(screenWidth.rounded()))
}
private var maxOpenHeight: Double {
let currentHeight = settingsController.settings.display.openHeight
let screenHeight = NSScreen.screens.map { $0.frame.height - 20 }.max() ?? 900
return max(currentHeight, Double(screenHeight.rounded()))
}
var body: some View {
Form {
Section("Display") {
Toggle("Show on all displays", isOn: settingsController.binding(\.display.showOnAllDisplays))
Toggle("Show menu bar icon", isOn: settingsController.binding(\.display.showMenuBarIcon))
Toggle("Launch at login", isOn: settingsController.binding(\.display.launchAtLogin))
.onChange(of: settingsController.settings.display.launchAtLogin) { _, newValue in
LaunchAtLoginHelper.setEnabled(newValue)
}
}
Section("Hover Behavior") {
Toggle("Open notch on hover", isOn: settingsController.binding(\.behavior.openNotchOnHover))
if settingsController.settings.behavior.openNotchOnHover {
HStack {
Text("Hover delay")
Slider(value: settingsController.binding(\.behavior.minimumHoverDuration), in: 0.0...2.0, step: 0.05)
Text(String(format: "%.2fs", settingsController.settings.behavior.minimumHoverDuration))
.monospacedDigit()
.frame(width: 50)
}
}
}
Section("Gestures") {
Toggle("Enable gestures", isOn: settingsController.binding(\.behavior.enableGestures))
if settingsController.settings.behavior.enableGestures {
HStack {
Text("Sensitivity")
Slider(value: settingsController.binding(\.behavior.gestureSensitivity), in: 0.1...1.0, step: 0.05)
Text(String(format: "%.2f", settingsController.settings.behavior.gestureSensitivity))
.monospacedDigit()
.frame(width: 50)
}
}
}
Section("Closed Notch Size") {
Picker("Notch screens", selection: settingsController.binding(\.display.notchHeightMode)) {
ForEach(NotchHeightMode.allCases) { mode in
Text(mode.label).tag(mode.rawValue)
}
}
if settingsController.settings.display.notchHeightMode == NotchHeightMode.custom.rawValue {
HStack {
Text("Custom height")
Slider(value: settingsController.binding(\.display.notchHeight), in: 16...64, step: 1)
Text("\(Int(settingsController.settings.display.notchHeight))pt")
.monospacedDigit()
.frame(width: 50)
}
}
Picker("Non-notch screens", selection: settingsController.binding(\.display.nonNotchHeightMode)) {
ForEach(NonNotchHeightMode.allCases) { mode in
Text(mode.label).tag(mode.rawValue)
}
}
if settingsController.settings.display.nonNotchHeightMode == NonNotchHeightMode.custom.rawValue {
HStack {
Text("Custom height")
Slider(value: settingsController.binding(\.display.nonNotchHeight), in: 16...64, step: 1)
Text("\(Int(settingsController.settings.display.nonNotchHeight))pt")
.monospacedDigit()
.frame(width: 50)
}
}
}
Section("Open Notch Size") {
HStack {
Text("Width")
Slider(value: settingsController.binding(\.display.openWidth), in: 320...maxOpenWidth, step: 10)
Text("\(Int(settingsController.settings.display.openWidth))pt")
.monospacedDigit()
.frame(width: 60)
}
HStack {
Text("Height")
Slider(value: settingsController.binding(\.display.openHeight), in: 140...maxOpenHeight, step: 10)
Text("\(Int(settingsController.settings.display.openHeight))pt")
.monospacedDigit()
.frame(width: 60)
}
}
}
.formStyle(.grouped)
}
}

View File

@@ -0,0 +1,36 @@
import SwiftUI
struct HotkeySettingsView: View {
@ObservedObject private var settingsController = AppSettingsController.shared
var body: some View {
Form {
Section("Global") {
HotkeyRecorderView(label: "Toggle notch", binding: settingsController.binding(\.hotkeys.toggle))
}
Section("Terminal Tabs (active when notch is open)") {
HotkeyRecorderView(label: "New tab", binding: settingsController.binding(\.hotkeys.newTab))
HotkeyRecorderView(label: "Close tab", binding: settingsController.binding(\.hotkeys.closeTab))
HotkeyRecorderView(label: "Next tab", binding: settingsController.binding(\.hotkeys.nextTab))
HotkeyRecorderView(label: "Previous tab", binding: settingsController.binding(\.hotkeys.previousTab))
HotkeyRecorderView(label: "Detach tab", binding: settingsController.binding(\.hotkeys.detachTab))
}
Section {
Text("⌘19 always switch to tab by number. Size preset hotkeys are configured in Terminal > Size Presets.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section {
Button("Reset to Defaults") {
settingsController.update {
$0.hotkeys = AppSettings.default.hotkeys
}
}
}
}
.formStyle(.grouped)
}
}

View File

@@ -0,0 +1,13 @@
import SwiftUI
@MainActor
extension AppSettingsController {
func binding<Value>(_ keyPath: WritableKeyPath<AppSettings, Value>) -> Binding<Value> {
Binding(
get: { self.settings[keyPath: keyPath] },
set: { newValue in
self.update { $0[keyPath: keyPath] = newValue }
}
)
}
}

View File

@@ -1,9 +1,6 @@
import SwiftUI
import AppKit
/// Tabbed settings panel with General, Appearance, Animation, Terminal, Hotkeys, and About.
struct SettingsView: View {
@State private var selectedTab: SettingsTab = .general
var body: some View {
@@ -11,6 +8,7 @@ struct SettingsView: View {
List(SettingsTab.allCases, selection: $selectedTab) { tab in
Label(tab.label, systemImage: tab.icon)
.tag(tab)
.accessibilityIdentifier("settings.tab.\(tab.rawValue)")
}
.listStyle(.sidebar)
.navigationSplitViewColumnWidth(min: 180, ideal: 200)
@@ -26,495 +24,64 @@ struct SettingsView: View {
@ViewBuilder
private var detailView: some View {
switch selectedTab {
case .general: GeneralSettingsView()
case .appearance: AppearanceSettingsView()
case .animation: AnimationSettingsView()
case .terminal: TerminalSettingsView()
case .hotkeys: HotkeySettingsView()
case .about: AboutSettingsView()
case .general:
GeneralSettingsView()
case .appearance:
AppearanceSettingsView()
case .workspaces:
WorkspacesSettingsView()
case .animation:
AnimationSettingsView()
case .terminal:
TerminalSettingsView()
case .hotkeys:
HotkeySettingsView()
case .about:
AboutSettingsView()
}
}
}
// MARK: - Tabs
enum SettingsTab: String, CaseIterable, Identifiable {
case general, appearance, animation, terminal, hotkeys, about
case general, appearance, workspaces, animation, terminal, hotkeys, about
var id: String { rawValue }
var label: String {
switch self {
case .general: return "General"
case .appearance: return "Appearance"
case .animation: return "Animation"
case .terminal: return "Terminal"
case .hotkeys: return "Hotkeys"
case .about: return "About"
case .general:
"General"
case .appearance:
"Appearance"
case .workspaces:
"Workspaces"
case .animation:
"Animation"
case .terminal:
"Terminal"
case .hotkeys:
"Hotkeys"
case .about:
"About"
}
}
var icon: String {
switch self {
case .general: return "gearshape"
case .appearance: return "paintbrush"
case .animation: return "bolt.fill"
case .terminal: return "terminal"
case .hotkeys: return "keyboard"
case .about: return "info.circle"
case .general:
"gearshape"
case .appearance:
"paintbrush"
case .workspaces:
"rectangle.3.group"
case .animation:
"bolt.fill"
case .terminal:
"terminal"
case .hotkeys:
"keyboard"
case .about:
"info.circle"
}
}
}
// MARK: - General
struct GeneralSettingsView: View {
@AppStorage(NotchSettings.Keys.showOnAllDisplays) private var showOnAllDisplays = NotchSettings.Defaults.showOnAllDisplays
@AppStorage(NotchSettings.Keys.openNotchOnHover) private var openNotchOnHover = NotchSettings.Defaults.openNotchOnHover
@AppStorage(NotchSettings.Keys.minimumHoverDuration) private var minimumHoverDuration = NotchSettings.Defaults.minimumHoverDuration
@AppStorage(NotchSettings.Keys.showMenuBarIcon) private var showMenuBarIcon = NotchSettings.Defaults.showMenuBarIcon
@AppStorage(NotchSettings.Keys.launchAtLogin) private var launchAtLogin = NotchSettings.Defaults.launchAtLogin
@AppStorage(NotchSettings.Keys.enableGestures) private var enableGestures = NotchSettings.Defaults.enableGestures
@AppStorage(NotchSettings.Keys.gestureSensitivity) private var gestureSensitivity = NotchSettings.Defaults.gestureSensitivity
@AppStorage(NotchSettings.Keys.notchHeightMode) private var notchHeightMode = NotchSettings.Defaults.notchHeightMode
@AppStorage(NotchSettings.Keys.notchHeight) private var notchHeight = NotchSettings.Defaults.notchHeight
@AppStorage(NotchSettings.Keys.nonNotchHeightMode) private var nonNotchHeightMode = NotchSettings.Defaults.nonNotchHeightMode
@AppStorage(NotchSettings.Keys.nonNotchHeight) private var nonNotchHeight = NotchSettings.Defaults.nonNotchHeight
@AppStorage(NotchSettings.Keys.openWidth) private var openWidth = NotchSettings.Defaults.openWidth
@AppStorage(NotchSettings.Keys.openHeight) private var openHeight = NotchSettings.Defaults.openHeight
private var maxOpenWidth: Double {
max(openWidth, Double((NSScreen.screens.map { $0.frame.width - 40 }.max() ?? 1600).rounded()))
}
private var maxOpenHeight: Double {
max(openHeight, Double((NSScreen.screens.map { $0.frame.height - 20 }.max() ?? 900).rounded()))
}
var body: some View {
Form {
Section("Display") {
Toggle("Show on all displays", isOn: $showOnAllDisplays)
Toggle("Show menu bar icon", isOn: $showMenuBarIcon)
Toggle("Launch at login", isOn: $launchAtLogin)
.onChange(of: launchAtLogin) { _, newValue in
LaunchAtLoginHelper.setEnabled(newValue)
}
}
Section("Hover Behavior") {
Toggle("Open notch on hover", isOn: $openNotchOnHover)
if openNotchOnHover {
HStack {
Text("Hover delay")
Slider(value: $minimumHoverDuration, in: 0.0...2.0, step: 0.05)
Text(String(format: "%.2fs", minimumHoverDuration))
.monospacedDigit().frame(width: 50)
}
}
}
Section("Gestures") {
Toggle("Enable gestures", isOn: $enableGestures)
if enableGestures {
HStack {
Text("Sensitivity")
Slider(value: $gestureSensitivity, in: 0.1...1.0, step: 0.05)
Text(String(format: "%.2f", gestureSensitivity))
.monospacedDigit().frame(width: 50)
}
}
}
Section("Closed Notch Size") {
Picker("Notch screens", selection: $notchHeightMode) {
ForEach(NotchHeightMode.allCases) { Text($0.label).tag($0.rawValue) }
}
if notchHeightMode == NotchHeightMode.custom.rawValue {
HStack {
Text("Custom height")
Slider(value: $notchHeight, in: 16...64, step: 1)
Text("\(Int(notchHeight))pt").monospacedDigit().frame(width: 50)
}
}
Picker("Non-notch screens", selection: $nonNotchHeightMode) {
ForEach(NonNotchHeightMode.allCases) { Text($0.label).tag($0.rawValue) }
}
if nonNotchHeightMode == NonNotchHeightMode.custom.rawValue {
HStack {
Text("Custom height")
Slider(value: $nonNotchHeight, in: 16...64, step: 1)
Text("\(Int(nonNotchHeight))pt").monospacedDigit().frame(width: 50)
}
}
}
Section("Open Notch Size") {
HStack {
Text("Width")
Slider(value: $openWidth, in: 320...maxOpenWidth, step: 10)
Text("\(Int(openWidth))pt").monospacedDigit().frame(width: 60)
}
HStack {
Text("Height")
Slider(value: $openHeight, in: 140...maxOpenHeight, step: 10)
Text("\(Int(openHeight))pt").monospacedDigit().frame(width: 60)
}
}
}
.formStyle(.grouped)
}
}
// MARK: - Appearance
struct AppearanceSettingsView: View {
@AppStorage(NotchSettings.Keys.enableShadow) private var enableShadow = NotchSettings.Defaults.enableShadow
@AppStorage(NotchSettings.Keys.shadowRadius) private var shadowRadius = NotchSettings.Defaults.shadowRadius
@AppStorage(NotchSettings.Keys.shadowOpacity) private var shadowOpacity = NotchSettings.Defaults.shadowOpacity
@AppStorage(NotchSettings.Keys.cornerRadiusScaling) private var cornerRadiusScaling = NotchSettings.Defaults.cornerRadiusScaling
@AppStorage(NotchSettings.Keys.notchOpacity) private var notchOpacity = NotchSettings.Defaults.notchOpacity
@AppStorage(NotchSettings.Keys.blurRadius) private var blurRadius = NotchSettings.Defaults.blurRadius
var body: some View {
Form {
Section("Shadow") {
Toggle("Enable shadow", isOn: $enableShadow)
if enableShadow {
HStack {
Text("Radius")
Slider(value: $shadowRadius, in: 0...30, step: 1)
Text(String(format: "%.0f", shadowRadius)).monospacedDigit().frame(width: 40)
}
HStack {
Text("Opacity")
Slider(value: $shadowOpacity, in: 0...1, step: 0.05)
Text(String(format: "%.2f", shadowOpacity)).monospacedDigit().frame(width: 50)
}
}
}
Section("Shape") {
Toggle("Scale corner radii when open", isOn: $cornerRadiusScaling)
}
Section("Opacity & Blur") {
HStack {
Text("Notch opacity")
Slider(value: $notchOpacity, in: 0...1, step: 0.05)
Text(String(format: "%.2f", notchOpacity)).monospacedDigit().frame(width: 50)
}
HStack {
Text("Blur radius")
Slider(value: $blurRadius, in: 0...20, step: 0.5)
Text(String(format: "%.1f", blurRadius)).monospacedDigit().frame(width: 50)
}
}
}
.formStyle(.grouped)
}
}
// MARK: - Animation
struct AnimationSettingsView: View {
@AppStorage(NotchSettings.Keys.openSpringResponse) private var openResponse = NotchSettings.Defaults.openSpringResponse
@AppStorage(NotchSettings.Keys.openSpringDamping) private var openDamping = NotchSettings.Defaults.openSpringDamping
@AppStorage(NotchSettings.Keys.closeSpringResponse) private var closeResponse = NotchSettings.Defaults.closeSpringResponse
@AppStorage(NotchSettings.Keys.closeSpringDamping) private var closeDamping = NotchSettings.Defaults.closeSpringDamping
@AppStorage(NotchSettings.Keys.hoverSpringResponse) private var hoverResponse = NotchSettings.Defaults.hoverSpringResponse
@AppStorage(NotchSettings.Keys.hoverSpringDamping) private var hoverDamping = NotchSettings.Defaults.hoverSpringDamping
@AppStorage(NotchSettings.Keys.resizeAnimationDuration) private var resizeDuration = NotchSettings.Defaults.resizeAnimationDuration
var body: some View {
Form {
Section("Open Animation") {
springControls(response: $openResponse, damping: $openDamping)
}
Section("Close Animation") {
springControls(response: $closeResponse, damping: $closeDamping)
}
Section("Hover Animation") {
springControls(response: $hoverResponse, damping: $hoverDamping)
}
Section("Resize Animation") {
durationControl(duration: $resizeDuration)
}
Section {
Button("Reset to Defaults") {
openResponse = NotchSettings.Defaults.openSpringResponse
openDamping = NotchSettings.Defaults.openSpringDamping
closeResponse = NotchSettings.Defaults.closeSpringResponse
closeDamping = NotchSettings.Defaults.closeSpringDamping
hoverResponse = NotchSettings.Defaults.hoverSpringResponse
hoverDamping = NotchSettings.Defaults.hoverSpringDamping
resizeDuration = NotchSettings.Defaults.resizeAnimationDuration
}
}
}
.formStyle(.grouped)
}
@ViewBuilder
private func springControls(response: Binding<Double>, damping: Binding<Double>) -> some View {
HStack {
Text("Response")
Slider(value: response, in: 0.1...1.5, step: 0.01)
Text(String(format: "%.2f", response.wrappedValue)).monospacedDigit().frame(width: 50)
}
HStack {
Text("Damping")
Slider(value: damping, in: 0.1...1.5, step: 0.01)
Text(String(format: "%.2f", damping.wrappedValue)).monospacedDigit().frame(width: 50)
}
}
@ViewBuilder
private func durationControl(duration: Binding<Double>) -> some View {
HStack {
Text("Duration")
Slider(value: duration, in: 0.05...1.5, step: 0.01)
Text(String(format: "%.2fs", duration.wrappedValue)).monospacedDigit().frame(width: 56)
}
}
}
// MARK: - Terminal
struct TerminalSettingsView: View {
@AppStorage(NotchSettings.Keys.terminalFontSize) private var fontSize = NotchSettings.Defaults.terminalFontSize
@AppStorage(NotchSettings.Keys.terminalShell) private var shellPath = NotchSettings.Defaults.terminalShell
@AppStorage(NotchSettings.Keys.terminalTheme) private var theme = NotchSettings.Defaults.terminalTheme
@AppStorage(NotchSettings.Keys.openWidth) private var openWidth = NotchSettings.Defaults.openWidth
@AppStorage(NotchSettings.Keys.openHeight) private var openHeight = NotchSettings.Defaults.openHeight
@State private var sizePresets = TerminalSizePresetStore.load()
var body: some View {
Form {
Section("Font") {
HStack {
Text("Font size")
Slider(value: $fontSize, in: 8...28, step: 1)
Text("\(Int(fontSize))pt").monospacedDigit().frame(width: 50)
}
}
Section("Colors") {
Picker("Theme", selection: $theme) {
ForEach(TerminalTheme.allCases) { terminalTheme in
Text(terminalTheme.label).tag(terminalTheme.rawValue)
}
}
Text(TerminalTheme.resolve(theme).detail)
.font(.caption)
.foregroundStyle(.secondary)
Text("Applies to normal terminal text and the ANSI palette used by tools like `ls`.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Shell") {
TextField("Shell path (empty = $SHELL)", text: $shellPath)
.textFieldStyle(.roundedBorder)
Text("Leave empty to use your default shell ($SHELL or /bin/zsh).")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Size Presets") {
ForEach($sizePresets) { $preset in
TerminalSizePresetEditor(
preset: $preset,
currentOpenWidth: openWidth,
currentOpenHeight: openHeight,
onDelete: { deletePreset(id: preset.id) },
onApply: { applyPreset(preset) }
)
}
HStack {
Button("Add Preset") {
sizePresets.append(
TerminalSizePreset(
name: "Preset \(sizePresets.count + 1)",
width: openWidth,
height: openHeight,
hotkey: TerminalSizePresetStore.suggestedHotkey(for: sizePresets)
)
)
}
Button("Reset Presets") {
sizePresets = TerminalSizePresetStore.loadDefaults()
}
}
Text("Size preset hotkeys are active when the notch is open. Default presets use ⌘⇧1, ⌘⇧2, and ⌘⇧3.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
.onChange(of: sizePresets) { _, newValue in
TerminalSizePresetStore.save(newValue)
}
}
private func deletePreset(id: UUID) {
sizePresets.removeAll { $0.id == id }
}
private func applyPreset(_ preset: TerminalSizePreset) {
openWidth = preset.width
openHeight = preset.height
ScreenManager.shared.applySizePreset(preset)
}
}
// MARK: - Hotkeys
struct HotkeySettingsView: View {
@State private var toggleBinding = loadBinding(NotchSettings.Keys.hotkeyToggle, fallback: .cmdReturn)
@State private var newTabBinding = loadBinding(NotchSettings.Keys.hotkeyNewTab, fallback: .cmdT)
@State private var closeTabBinding = loadBinding(NotchSettings.Keys.hotkeyCloseTab, fallback: .cmdW)
@State private var nextTabBinding = loadBinding(NotchSettings.Keys.hotkeyNextTab, fallback: .cmdShiftRB)
@State private var prevTabBinding = loadBinding(NotchSettings.Keys.hotkeyPreviousTab, fallback: .cmdShiftLB)
@State private var detachBinding = loadBinding(NotchSettings.Keys.hotkeyDetachTab, fallback: .cmdD)
var body: some View {
Form {
Section("Global") {
HotkeyRecorderView(label: "Toggle notch", binding: bindAndSave($toggleBinding, key: NotchSettings.Keys.hotkeyToggle))
}
Section("Terminal Tabs (active when notch is open)") {
HotkeyRecorderView(label: "New tab", binding: bindAndSave($newTabBinding, key: NotchSettings.Keys.hotkeyNewTab))
HotkeyRecorderView(label: "Close tab", binding: bindAndSave($closeTabBinding, key: NotchSettings.Keys.hotkeyCloseTab))
HotkeyRecorderView(label: "Next tab", binding: bindAndSave($nextTabBinding, key: NotchSettings.Keys.hotkeyNextTab))
HotkeyRecorderView(label: "Previous tab", binding: bindAndSave($prevTabBinding, key: NotchSettings.Keys.hotkeyPreviousTab))
HotkeyRecorderView(label: "Detach tab", binding: bindAndSave($detachBinding, key: NotchSettings.Keys.hotkeyDetachTab))
}
Section {
Text("⌘19 always switch to tab by number. Size preset hotkeys are configured in Terminal > Size Presets.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section {
Button("Reset to Defaults") {
toggleBinding = .cmdReturn
newTabBinding = .cmdT
closeTabBinding = .cmdW
nextTabBinding = .cmdShiftRB
prevTabBinding = .cmdShiftLB
detachBinding = .cmdD
save(.cmdReturn, key: NotchSettings.Keys.hotkeyToggle)
save(.cmdT, key: NotchSettings.Keys.hotkeyNewTab)
save(.cmdW, key: NotchSettings.Keys.hotkeyCloseTab)
save(.cmdShiftRB, key: NotchSettings.Keys.hotkeyNextTab)
save(.cmdShiftLB, key: NotchSettings.Keys.hotkeyPreviousTab)
save(.cmdD, key: NotchSettings.Keys.hotkeyDetachTab)
}
}
}
.formStyle(.grouped)
}
/// Creates a binding that saves to UserDefaults on every change.
private func bindAndSave(_ state: Binding<HotkeyBinding>, key: String) -> Binding<HotkeyBinding> {
Binding(
get: { state.wrappedValue },
set: { newValue in
state.wrappedValue = newValue
save(newValue, key: key)
}
)
}
private func save(_ binding: HotkeyBinding, key: String) {
UserDefaults.standard.set(binding.toJSON(), forKey: key)
}
private static func loadBinding(_ key: String, fallback: HotkeyBinding) -> HotkeyBinding {
guard let json = UserDefaults.standard.string(forKey: key),
let b = HotkeyBinding.fromJSON(json) else { return fallback }
return b
}
}
private struct TerminalSizePresetEditor: View {
@Binding var preset: TerminalSizePreset
let currentOpenWidth: Double
let currentOpenHeight: Double
let onDelete: () -> Void
let onApply: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
TextField("Preset name", text: $preset.name)
.textFieldStyle(.roundedBorder)
Button(role: .destructive, action: onDelete) {
Image(systemName: "trash")
}
.buttonStyle(.borderless)
}
HStack {
Text("Width")
TextField("Width", value: $preset.width, format: .number.precision(.fractionLength(0)))
.textFieldStyle(.roundedBorder)
.frame(width: 90)
Text("Height")
TextField("Height", value: $preset.height, format: .number.precision(.fractionLength(0)))
.textFieldStyle(.roundedBorder)
.frame(width: 90)
Spacer()
Button("Use Current Size") {
preset.width = currentOpenWidth
preset.height = currentOpenHeight
}
Button("Apply", action: onApply)
}
OptionalHotkeyRecorderView(label: "Hotkey", binding: $preset.hotkey)
}
.padding(.vertical, 4)
}
}
// MARK: - About
struct AboutSettingsView: View {
var body: some View {
VStack(spacing: 16) {
Image(systemName: "terminal")
.font(.system(size: 64))
.foregroundStyle(.secondary)
Text("CommandNotch")
.font(.largeTitle.bold())
Text("Version 0.3.0")
.foregroundStyle(.secondary)
Text("A drop-down terminal that lives in your notch.")
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
Spacer()
}
.frame(maxWidth: .infinity)
.padding(.top, 40)
}
}

View File

@@ -0,0 +1,152 @@
import SwiftUI
struct TerminalSettingsView: View {
@ObservedObject private var settingsController = AppSettingsController.shared
private var sizePresetsBinding: Binding<[TerminalSizePreset]> {
Binding(
get: {
TerminalSizePresetStore.decodePresets(
from: settingsController.settings.terminal.sizePresetsJSON
) ?? TerminalSizePresetStore.loadDefaults()
},
set: { newValue in
settingsController.update {
$0.terminal.sizePresetsJSON = TerminalSizePresetStore.encodePresets(newValue)
}
}
)
}
var body: some View {
Form {
Section("Font") {
HStack {
Text("Font size")
Slider(value: settingsController.binding(\.terminal.fontSize), in: 8...28, step: 1)
Text("\(Int(settingsController.settings.terminal.fontSize))pt")
.monospacedDigit()
.frame(width: 50)
}
}
Section("Colors") {
Picker("Theme", selection: settingsController.binding(\.terminal.themeRawValue)) {
ForEach(TerminalTheme.allCases) { terminalTheme in
Text(terminalTheme.label).tag(terminalTheme.rawValue)
}
}
Text(settingsController.settings.terminal.theme.detail)
.font(.caption)
.foregroundStyle(.secondary)
Text("Applies to normal terminal text and the ANSI palette used by tools like `ls`.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Shell") {
TextField("Shell path (empty = $SHELL)", text: settingsController.binding(\.terminal.shellPath))
.textFieldStyle(.roundedBorder)
Text("Leave empty to use your default shell ($SHELL or /bin/zsh).")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Size Presets") {
ForEach(sizePresetsBinding) { $preset in
TerminalSizePresetEditor(
preset: $preset,
currentOpenWidth: settingsController.settings.display.openWidth,
currentOpenHeight: settingsController.settings.display.openHeight,
onDelete: { deletePreset(id: preset.id) },
onApply: { applyPreset(preset) }
)
}
HStack {
Button("Add Preset") {
var presets = sizePresetsBinding.wrappedValue
presets.append(
TerminalSizePreset(
name: "Preset \(presets.count + 1)",
width: settingsController.settings.display.openWidth,
height: settingsController.settings.display.openHeight,
hotkey: TerminalSizePresetStore.suggestedHotkey(for: presets)
)
)
sizePresetsBinding.wrappedValue = presets
}
Button("Reset Presets") {
sizePresetsBinding.wrappedValue = TerminalSizePresetStore.loadDefaults()
}
}
Text("Size preset hotkeys are active when the notch is open. Default presets use ⌘⇧1, ⌘⇧2, and ⌘⇧3.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
}
private func deletePreset(id: UUID) {
sizePresetsBinding.wrappedValue.removeAll { $0.id == id }
}
private func applyPreset(_ preset: TerminalSizePreset) {
settingsController.update {
$0.display.openWidth = preset.width
$0.display.openHeight = preset.height
}
ScreenManager.shared.applySizePreset(preset)
}
}
private struct TerminalSizePresetEditor: View {
@Binding var preset: TerminalSizePreset
let currentOpenWidth: Double
let currentOpenHeight: Double
let onDelete: () -> Void
let onApply: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
TextField("Preset name", text: $preset.name)
.textFieldStyle(.roundedBorder)
Button(role: .destructive, action: onDelete) {
Image(systemName: "trash")
}
.buttonStyle(.borderless)
}
HStack {
Text("Width")
TextField("Width", value: $preset.width, format: .number.precision(.fractionLength(0)))
.textFieldStyle(.roundedBorder)
.frame(width: 90)
Text("Height")
TextField("Height", value: $preset.height, format: .number.precision(.fractionLength(0)))
.textFieldStyle(.roundedBorder)
.frame(width: 90)
Spacer()
Button("Use Current Size") {
preset.width = currentOpenWidth
preset.height = currentOpenHeight
}
Button("Apply", action: onApply)
}
OptionalHotkeyRecorderView(label: "Hotkey", binding: $preset.hotkey)
}
.padding(.vertical, 4)
}
}

View File

@@ -0,0 +1,161 @@
import SwiftUI
struct WorkspaceSwitcherView: View {
@ObservedObject var screen: ScreenContext
let orchestrator: NotchOrchestrator
@ObservedObject private var screenRegistry = ScreenRegistry.shared
@ObservedObject private var workspaceRegistry = WorkspaceRegistry.shared
@State private var isRenameAlertPresented = false
@State private var isDeleteConfirmationPresented = false
@State private var renameDraft = ""
private var currentWorkspaceSummary: WorkspaceSummary {
workspaceRegistry.summary(for: screen.workspaceID)
?? workspaceRegistry.allWorkspaceSummaries().first
?? WorkspaceSummary(id: screen.workspaceID, name: "Workspace")
}
private var deletionFallbackSummary: WorkspaceSummary? {
guard let fallbackWorkspaceID = workspaceRegistry.deletionFallbackWorkspaceID(
forDeleting: screen.workspaceID,
preferredFallback: workspaceRegistry.defaultWorkspaceID
) else {
return nil
}
return workspaceRegistry.summary(for: fallbackWorkspaceID)
}
private var assignedScreenCount: Int {
screenRegistry.assignedScreenCount(to: screen.workspaceID)
}
var body: some View {
Menu {
ForEach(workspaceRegistry.workspaceSummaries) { summary in
Button {
selectWorkspace(summary.id)
} label: {
if summary.id == screen.workspaceID {
Label(summary.name, systemImage: "checkmark")
} else {
Text(summary.name)
}
}
}
Divider()
Button("New Workspace") {
let workspaceID = workspaceRegistry.createWorkspace()
selectWorkspace(workspaceID)
}
Button("Rename Current Workspace") {
renameDraft = currentWorkspaceSummary.name
syncFocusLossSuppression(renamePresented: true, deletePresented: isDeleteConfirmationPresented)
isRenameAlertPresented = true
}
Button("Delete Current Workspace", role: .destructive) {
syncFocusLossSuppression(renamePresented: isRenameAlertPresented, deletePresented: true)
isDeleteConfirmationPresented = true
}
.disabled(!workspaceRegistry.canDeleteWorkspace(id: screen.workspaceID))
} label: {
switcherLabel
}
.menuStyle(.borderlessButton)
.accessibilityIdentifier("notch.workspace-switcher")
.accessibilityLabel("Workspace Switcher")
.accessibilityValue(currentWorkspaceSummary.name)
.fixedSize(horizontal: false, vertical: true)
.help("Switch workspace for this screen")
.alert("Rename Workspace", isPresented: $isRenameAlertPresented) {
TextField("Workspace name", text: $renameDraft)
Button("Cancel", role: .cancel) {}
Button("Save") {
workspaceRegistry.renameWorkspace(id: screen.workspaceID, to: renameDraft)
}
} message: {
Text("This only renames the shared workspace. Screens assigned to it keep following the new name.")
}
.confirmationDialog("Delete Workspace", isPresented: $isDeleteConfirmationPresented, titleVisibility: .visible) {
Button("Delete Workspace", role: .destructive) {
deleteCurrentWorkspace()
}
} message: {
Text(deleteMessage)
}
.onChange(of: isRenameAlertPresented) { _, isPresented in
syncFocusLossSuppression(renamePresented: isPresented, deletePresented: isDeleteConfirmationPresented)
}
.onChange(of: isDeleteConfirmationPresented) { _, isPresented in
syncFocusLossSuppression(renamePresented: isRenameAlertPresented, deletePresented: isPresented)
}
.onDisappear {
screen.setCloseOnFocusLossSuppressed(false)
}
}
private var deleteMessage: String {
if let fallback = deletionFallbackSummary {
return "This reassigns \(assignedScreenCount) screen\(assignedScreenCount == 1 ? "" : "s") to \(fallback.name) and closes this workspace."
}
return "At least one workspace must remain."
}
private var switcherLabel: some View {
HStack(spacing: 6) {
Image(systemName: "rectangle.3.group")
.font(.system(size: 11, weight: .medium))
Text(currentWorkspaceSummary.name)
.font(.system(size: 11, weight: .medium))
.lineLimit(1)
Image(systemName: "chevron.down")
.font(.system(size: 9, weight: .semibold))
}
.foregroundStyle(.white.opacity(0.7))
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(Color.white.opacity(0.08))
)
.contentShape(Rectangle())
.accessibilityElement(children: .ignore)
.accessibilityLabel("Workspace Switcher")
.accessibilityValue(currentWorkspaceSummary.name)
.accessibilityIdentifier("notch.workspace-switcher")
}
private func selectWorkspace(_ workspaceID: WorkspaceID) {
screenRegistry.assignWorkspace(workspaceID, to: screen.id)
if screen.notchState == .open {
orchestrator.open(screenID: screen.id)
}
}
private func deleteCurrentWorkspace() {
guard let fallback = screenRegistry.deleteWorkspace(
screen.workspaceID,
preferredFallback: workspaceRegistry.defaultWorkspaceID
) else {
return
}
screenRegistry.assignWorkspace(fallback, to: screen.id)
if screen.notchState == .open {
orchestrator.open(screenID: screen.id)
} else {
screen.requestTerminalFocus?()
}
}
private func syncFocusLossSuppression(renamePresented: Bool, deletePresented: Bool) {
screen.setCloseOnFocusLossSuppressed(renamePresented || deletePresented)
}
}

View File

@@ -0,0 +1,271 @@
import SwiftUI
struct WorkspacesSettingsView: View {
@ObservedObject private var workspaceRegistry = WorkspaceRegistry.shared
@ObservedObject private var screenRegistry = ScreenRegistry.shared
@State private var selectedWorkspaceID: WorkspaceID?
@State private var renameDraft = ""
@State private var isDeleteAlertPresented = false
private var effectiveSelectedWorkspaceID: WorkspaceID? {
selectedWorkspaceID ?? workspaceRegistry.workspaceSummaries.first?.id
}
private var selectedSummary: WorkspaceSummary? {
guard let effectiveSelectedWorkspaceID else { return nil }
return workspaceRegistry.summary(for: effectiveSelectedWorkspaceID)
}
private var selectedController: WorkspaceController? {
guard let effectiveSelectedWorkspaceID else { return nil }
return workspaceRegistry.controller(for: effectiveSelectedWorkspaceID)
}
private var selectedAssignedScreenIDs: [ScreenID] {
guard let effectiveSelectedWorkspaceID else { return [] }
return screenRegistry.assignedScreenIDs(to: effectiveSelectedWorkspaceID)
}
private var connectedScreenSummaries: [ConnectedScreenSummary] {
screenRegistry.connectedScreenSummaries()
}
private var activeConnectedScreenSummary: ConnectedScreenSummary? {
connectedScreenSummaries.first(where: \.isActive)
}
private var deletionFallbackSummary: WorkspaceSummary? {
guard let effectiveSelectedWorkspaceID,
let fallbackID = workspaceRegistry.deletionFallbackWorkspaceID(
forDeleting: effectiveSelectedWorkspaceID,
preferredFallback: workspaceRegistry.defaultWorkspaceID
) else {
return nil
}
return workspaceRegistry.summary(for: fallbackID)
}
var body: some View {
HStack(spacing: 20) {
List(selection: $selectedWorkspaceID) {
ForEach(workspaceRegistry.workspaceSummaries) { summary in
VStack(alignment: .leading, spacing: 4) {
Text(summary.name)
.font(.headline)
Text(usageDescription(for: summary))
.font(.caption)
.foregroundStyle(.secondary)
}
.tag(summary.id)
.accessibilityIdentifier("settings.workspace.row.\(summary.id.uuidString)")
}
}
.accessibilityIdentifier("settings.workspaces.list")
.frame(minWidth: 220, idealWidth: 240, maxWidth: 260, maxHeight: .infinity)
if let summary = selectedSummary {
Form {
Section("Identity") {
TextField("Workspace name", text: $renameDraft)
.accessibilityIdentifier("settings.workspaces.name-field")
.onSubmit {
renameSelectedWorkspace()
}
HStack {
Button("Save Name") {
renameSelectedWorkspace()
}
.accessibilityIdentifier("settings.workspaces.save-name")
Button("New Workspace") {
createWorkspace()
}
.accessibilityIdentifier("settings.workspaces.new")
}
}
Section("Usage") {
LabeledContent("Assigned screens") {
Text("\(selectedAssignedScreenIDs.count)")
.accessibilityIdentifier("settings.workspaces.assigned-count")
}
LabeledContent("Open tabs") {
Text("\(selectedController?.tabs.count ?? 0)")
}
if selectedAssignedScreenIDs.isEmpty {
Text("No screens are currently assigned to this workspace.")
.foregroundStyle(.secondary)
} else {
ForEach(selectedAssignedScreenIDs, id: \.self) { screenID in
LabeledContent("Screen") {
Text(screenID)
.font(.caption.monospaced())
}
}
}
}
Section("Shared Workspace Rules") {
Text(sharedWorkspaceDescription(for: selectedAssignedScreenIDs.count))
.foregroundStyle(.secondary)
}
Section("Connected Screens") {
if let activeScreen = activeConnectedScreenSummary {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(activeScreen.displayName)
Text(activeScreen.id)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
}
Spacer()
Button(activeScreen.assignedWorkspaceID == summary.id ? "Assigned Here" : "Assign Current Screen") {
screenRegistry.assignWorkspace(summary.id, to: activeScreen.id)
}
.accessibilityIdentifier("settings.workspaces.assign-current")
.disabled(activeScreen.assignedWorkspaceID == summary.id)
}
} else {
Text("No connected screens are currently available.")
.foregroundStyle(.secondary)
}
ForEach(connectedScreenSummaries.filter { !$0.isActive }) { screen in
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(screen.displayName)
Text(screen.id)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
}
Spacer()
Button(screen.assignedWorkspaceID == summary.id ? "Assigned Here" : "Assign Here") {
screenRegistry.assignWorkspace(summary.id, to: screen.id)
}
.accessibilityIdentifier("settings.workspaces.assign.\(screen.id)")
.disabled(screen.assignedWorkspaceID == summary.id)
}
}
}
Section("Danger Zone") {
Button("Delete Workspace", role: .destructive) {
isDeleteAlertPresented = true
}
.accessibilityIdentifier("settings.workspaces.delete")
.disabled(!workspaceRegistry.canDeleteWorkspace(id: summary.id))
if !workspaceRegistry.canDeleteWorkspace(id: summary.id) {
Text("At least one workspace must remain.")
.foregroundStyle(.secondary)
}
}
}
.formStyle(.grouped)
} else {
ContentUnavailableView(
"No Workspaces",
systemImage: "rectangle.3.group",
description: Text("Create a workspace to start grouping tabs across screens.")
)
}
}
.onAppear {
selectInitialWorkspaceIfNeeded()
}
.onChange(of: workspaceRegistry.workspaceSummaries) { _, _ in
synchronizeSelectionWithRegistry()
}
.onChange(of: selectedWorkspaceID) { _, _ in
renameDraft = selectedSummary?.name ?? ""
}
.alert("Delete Workspace", isPresented: $isDeleteAlertPresented) {
Button("Cancel", role: .cancel) {}
Button("Delete", role: .destructive) {
deleteSelectedWorkspace()
}
} message: {
if let summary = selectedSummary, let fallback = deletionFallbackSummary {
Text(
"Deleting \(summary.name) reassigns its screens to \(fallback.name) and closes the workspace."
)
} else {
Text("At least one workspace must remain.")
}
}
}
private func usageDescription(for summary: WorkspaceSummary) -> String {
let screenCount = screenRegistry.assignedScreenCount(to: summary.id)
let tabCount = workspaceRegistry.controller(for: summary.id)?.tabs.count ?? 0
return "\(screenCount) screen\(screenCount == 1 ? "" : "s") · \(tabCount) tab\(tabCount == 1 ? "" : "s")"
}
private func sharedWorkspaceDescription(for screenCount: Int) -> String {
if screenCount > 1 {
return "This workspace is shared across \(screenCount) screens. Tab changes stay in sync across each assigned screen."
}
if screenCount == 1 {
return "This workspace is assigned to one screen. You can assign additional screens to share the same tabs."
}
return "Unassigned workspaces keep their tabs and can be attached to any screen later."
}
private func selectInitialWorkspaceIfNeeded() {
if selectedWorkspaceID == nil {
selectedWorkspaceID = workspaceRegistry.workspaceSummaries.first?.id
}
renameDraft = selectedSummary?.name ?? ""
}
private func synchronizeSelectionWithRegistry() {
guard let selectedWorkspaceID else {
selectInitialWorkspaceIfNeeded()
return
}
if workspaceRegistry.summary(for: selectedWorkspaceID) == nil {
self.selectedWorkspaceID = workspaceRegistry.workspaceSummaries.first?.id
}
renameDraft = selectedSummary?.name ?? ""
}
private func renameSelectedWorkspace() {
guard let effectiveSelectedWorkspaceID else { return }
workspaceRegistry.renameWorkspace(id: effectiveSelectedWorkspaceID, to: renameDraft)
renameDraft = selectedSummary?.name ?? renameDraft
}
private func createWorkspace() {
let workspaceID = workspaceRegistry.createWorkspace()
selectedWorkspaceID = workspaceID
renameDraft = workspaceRegistry.summary(for: workspaceID)?.name ?? ""
}
private func deleteSelectedWorkspace() {
guard let effectiveSelectedWorkspaceID,
let fallbackWorkspaceID = screenRegistry.deleteWorkspace(
effectiveSelectedWorkspaceID,
preferredFallback: workspaceRegistry.defaultWorkspaceID
) else {
return
}
self.selectedWorkspaceID = fallbackWorkspaceID
renameDraft = workspaceRegistry.summary(for: fallbackWorkspaceID)?.name ?? ""
}
}