File system cleanup

This commit is contained in:
2026-03-13 21:26:06 +11:00
parent 8ecb7d4382
commit cf3dba8fe4
83 changed files with 231 additions and 3 deletions

View File

@@ -0,0 +1,165 @@
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,
nextWorkspace: .cmdShiftDown,
previousWorkspace: .cmdShiftUp,
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 nextWorkspace: HotkeyBinding
var previousWorkspace: 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,140 @@
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),
nextWorkspace: hotkey(NotchSettings.Keys.hotkeyNextWorkspace, default: .cmdShiftDown),
previousWorkspace: hotkey(NotchSettings.Keys.hotkeyPreviousWorkspace, default: .cmdShiftUp),
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.nextWorkspace.toJSON(), forKey: NotchSettings.Keys.hotkeyNextWorkspace)
defaults.set(settings.hotkeys.previousWorkspace.toJSON(), forKey: NotchSettings.Keys.hotkeyPreviousWorkspace)
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,115 @@
import AppKit
import Carbon.HIToolbox
/// Serializable representation of a keyboard shortcut (modifier flags + key code).
/// Stored in UserDefaults as a JSON string.
struct HotkeyBinding: Codable, Equatable, Hashable {
var modifiers: UInt // NSEvent.ModifierFlags.rawValue, masked to cmd/shift/ctrl/opt
var keyCode: UInt16
/// Checks whether the given NSEvent matches this binding.
func matches(_ event: NSEvent) -> Bool {
let mask = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
let relevantFlags: NSEvent.ModifierFlags = [.command, .shift, .control, .option]
return mask.intersection(relevantFlags).rawValue == modifiers
&& event.keyCode == keyCode
}
/// Human-readable label like "" or "T".
var displayString: String {
var parts: [String] = []
let flags = NSEvent.ModifierFlags(rawValue: modifiers)
if flags.contains(.control) { parts.append("") }
if flags.contains(.option) { parts.append("") }
if flags.contains(.shift) { parts.append("") }
if flags.contains(.command) { parts.append("") }
parts.append(keyName)
return parts.joined()
}
private var keyName: String {
switch keyCode {
case 36: return ""
case 48: return ""
case 49: return "Space"
case 51: return ""
case 53: return ""
case 123: return ""
case 124: return ""
case 125: return ""
case 126: return ""
default:
// Try to get the character from the key code
let src = TISCopyCurrentKeyboardInputSource().takeRetainedValue()
let layoutDataRef = TISGetInputSourceProperty(src, kTISPropertyUnicodeKeyLayoutData)
if let layoutDataRef = layoutDataRef {
let layoutData = unsafeBitCast(layoutDataRef, to: CFData.self) as Data
var deadKeyState: UInt32 = 0
var length = 0
var chars = [UniChar](repeating: 0, count: 4)
layoutData.withUnsafeBytes { ptr in
let layoutPtr = ptr.baseAddress!.assumingMemoryBound(to: UCKeyboardLayout.self)
UCKeyTranslate(
layoutPtr,
keyCode,
UInt16(kUCKeyActionDisplay),
0, // no modifiers for the base character
UInt32(LMGetKbdType()),
UInt32(kUCKeyTranslateNoDeadKeysBit),
&deadKeyState,
4,
&length,
&chars
)
}
if length > 0 {
return String(utf16CodeUnits: chars, count: length).uppercased()
}
}
return "Key\(keyCode)"
}
}
// MARK: - Serialization
func toJSON() -> String {
(try? String(data: JSONEncoder().encode(self), encoding: .utf8)) ?? "{}"
}
static func fromJSON(_ string: String) -> HotkeyBinding? {
guard let data = string.data(using: .utf8) else { return nil }
return try? JSONDecoder().decode(HotkeyBinding.self, from: data)
}
// MARK: - Presets
static let cmdReturn = HotkeyBinding(modifiers: NSEvent.ModifierFlags.command.rawValue, keyCode: 36)
static let cmdT = HotkeyBinding(modifiers: NSEvent.ModifierFlags.command.rawValue, keyCode: 17)
static let cmdW = HotkeyBinding(modifiers: NSEvent.ModifierFlags.command.rawValue, keyCode: 13)
static let cmdShiftRB = HotkeyBinding(modifiers: NSEvent.ModifierFlags([.command, .shift]).rawValue, keyCode: 30) // ]
static let cmdShiftLB = HotkeyBinding(modifiers: NSEvent.ModifierFlags([.command, .shift]).rawValue, keyCode: 33) // [
static let cmdShiftDown = HotkeyBinding(modifiers: NSEvent.ModifierFlags([.command, .shift]).rawValue, keyCode: 125)
static let cmdShiftUp = HotkeyBinding(modifiers: NSEvent.ModifierFlags([.command, .shift]).rawValue, keyCode: 126)
static let cmdD = HotkeyBinding(modifiers: NSEvent.ModifierFlags.command.rawValue, keyCode: 2)
static func cmdShiftDigit(_ digit: Int) -> HotkeyBinding? {
guard let keyCode = keyCode(forDigit: digit) else { return nil }
return HotkeyBinding(modifiers: NSEvent.ModifierFlags([.command, .shift]).rawValue, keyCode: keyCode)
}
static func keyCode(forDigit digit: Int) -> UInt16? {
switch digit {
case 0: return 29
case 1: return 18
case 2: return 19
case 3: return 20
case 4: return 21
case 5: return 23
case 6: return 22
case 7: return 26
case 8: return 28
case 9: return 25
default: return nil
}
}
}

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

@@ -0,0 +1,276 @@
import Foundation
import AppKit
/// Central registry of all user-configurable notch settings.
enum NotchSettings {
enum Keys {
// General
static let showOnAllDisplays = "showOnAllDisplays"
static let openNotchOnHover = "openNotchOnHover"
static let minimumHoverDuration = "minimumHoverDuration"
static let showMenuBarIcon = "showMenuBarIcon"
static let launchAtLogin = "launchAtLogin"
// Sizing closed state
static let notchHeight = "notchHeight"
static let nonNotchHeight = "nonNotchHeight"
static let notchHeightMode = "notchHeightMode"
static let nonNotchHeightMode = "nonNotchHeightMode"
// Sizing open state
static let openWidth = "openWidth"
static let openHeight = "openHeight"
// Appearance
static let enableShadow = "enableShadow"
static let shadowRadius = "shadowRadius"
static let shadowOpacity = "shadowOpacity"
static let cornerRadiusScaling = "cornerRadiusScaling"
static let notchOpacity = "notchOpacity"
static let blurRadius = "blurRadius"
// Animation
static let openSpringResponse = "openSpringResponse"
static let openSpringDamping = "openSpringDamping"
static let closeSpringResponse = "closeSpringResponse"
static let closeSpringDamping = "closeSpringDamping"
static let hoverSpringResponse = "hoverSpringResponse"
static let hoverSpringDamping = "hoverSpringDamping"
static let resizeAnimationDuration = "resizeAnimationDuration"
// Behavior
static let enableGestures = "enableGestures"
static let gestureSensitivity = "gestureSensitivity"
// Terminal
static let terminalFontSize = "terminalFontSize"
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"
static let hotkeyNewTab = "hotkey_newTab"
static let hotkeyCloseTab = "hotkey_closeTab"
static let hotkeyNextTab = "hotkey_nextTab"
static let hotkeyPreviousTab = "hotkey_previousTab"
static let hotkeyNextWorkspace = "hotkey_nextWorkspace"
static let hotkeyPreviousWorkspace = "hotkey_previousWorkspace"
static let hotkeyDetachTab = "hotkey_detachTab"
}
enum Defaults {
static let showOnAllDisplays: Bool = true
static let openNotchOnHover: Bool = true
static let minimumHoverDuration: Double = 0.3
static let showMenuBarIcon: Bool = true
static let launchAtLogin: Bool = false
static let notchHeight: Double = 32
static let nonNotchHeight: Double = 32
static let notchHeightMode: Int = 0
static let nonNotchHeightMode: Int = 1
static let openWidth: Double = 640
static let openHeight: Double = 350
static let enableShadow: Bool = true
static let shadowRadius: Double = 6
static let shadowOpacity: Double = 0.5
static let cornerRadiusScaling: Bool = true
static let notchOpacity: Double = 1.0
static let blurRadius: Double = 0
static let openSpringResponse: Double = 0.42
static let openSpringDamping: Double = 0.8
static let closeSpringResponse: Double = 0.45
static let closeSpringDamping: Double = 1.0
static let hoverSpringResponse: Double = 0.38
static let hoverSpringDamping: Double = 0.8
static let resizeAnimationDuration: Double = 0.42
static let enableGestures: Bool = true
static let gestureSensitivity: Double = 0.5
static let terminalFontSize: Double = 13
static let terminalShell: String = ""
static let terminalTheme: String = TerminalTheme.terminalApp.rawValue
static let terminalSizePresets: String = TerminalSizePresetStore.defaultPresetsJSON()
// Default hotkey bindings as JSON
static let hotkeyToggle: String = HotkeyBinding.cmdReturn.toJSON()
static let hotkeyNewTab: String = HotkeyBinding.cmdT.toJSON()
static let hotkeyCloseTab: String = HotkeyBinding.cmdW.toJSON()
static let hotkeyNextTab: String = HotkeyBinding.cmdShiftRB.toJSON()
static let hotkeyPreviousTab: String = HotkeyBinding.cmdShiftLB.toJSON()
static let hotkeyNextWorkspace: String = HotkeyBinding.cmdShiftDown.toJSON()
static let hotkeyPreviousWorkspace: String = HotkeyBinding.cmdShiftUp.toJSON()
static let hotkeyDetachTab: String = HotkeyBinding.cmdD.toJSON()
}
static func registerDefaults() {
UserDefaults.standard.register(defaults: [
Keys.showOnAllDisplays: Defaults.showOnAllDisplays,
Keys.openNotchOnHover: Defaults.openNotchOnHover,
Keys.minimumHoverDuration: Defaults.minimumHoverDuration,
Keys.showMenuBarIcon: Defaults.showMenuBarIcon,
Keys.launchAtLogin: Defaults.launchAtLogin,
Keys.notchHeight: Defaults.notchHeight,
Keys.nonNotchHeight: Defaults.nonNotchHeight,
Keys.notchHeightMode: Defaults.notchHeightMode,
Keys.nonNotchHeightMode: Defaults.nonNotchHeightMode,
Keys.openWidth: Defaults.openWidth,
Keys.openHeight: Defaults.openHeight,
Keys.enableShadow: Defaults.enableShadow,
Keys.shadowRadius: Defaults.shadowRadius,
Keys.shadowOpacity: Defaults.shadowOpacity,
Keys.cornerRadiusScaling: Defaults.cornerRadiusScaling,
Keys.notchOpacity: Defaults.notchOpacity,
Keys.blurRadius: Defaults.blurRadius,
Keys.openSpringResponse: Defaults.openSpringResponse,
Keys.openSpringDamping: Defaults.openSpringDamping,
Keys.closeSpringResponse: Defaults.closeSpringResponse,
Keys.closeSpringDamping: Defaults.closeSpringDamping,
Keys.hoverSpringResponse: Defaults.hoverSpringResponse,
Keys.hoverSpringDamping: Defaults.hoverSpringDamping,
Keys.resizeAnimationDuration: Defaults.resizeAnimationDuration,
Keys.enableGestures: Defaults.enableGestures,
Keys.gestureSensitivity: Defaults.gestureSensitivity,
Keys.terminalFontSize: Defaults.terminalFontSize,
Keys.terminalShell: Defaults.terminalShell,
Keys.terminalTheme: Defaults.terminalTheme,
Keys.terminalSizePresets: Defaults.terminalSizePresets,
Keys.hotkeyToggle: Defaults.hotkeyToggle,
Keys.hotkeyNewTab: Defaults.hotkeyNewTab,
Keys.hotkeyCloseTab: Defaults.hotkeyCloseTab,
Keys.hotkeyNextTab: Defaults.hotkeyNextTab,
Keys.hotkeyPreviousTab: Defaults.hotkeyPreviousTab,
Keys.hotkeyNextWorkspace: Defaults.hotkeyNextWorkspace,
Keys.hotkeyPreviousWorkspace: Defaults.hotkeyPreviousWorkspace,
Keys.hotkeyDetachTab: Defaults.hotkeyDetachTab,
])
}
}
enum NotchHeightMode: Int, CaseIterable, Identifiable {
case matchRealNotchSize = 0
case matchMenuBar = 1
case custom = 2
var id: Int { rawValue }
var label: String {
switch self {
case .matchRealNotchSize: return "Match Notch"
case .matchMenuBar: return "Match Menu Bar"
case .custom: return "Custom"
}
}
}
enum NonNotchHeightMode: Int, CaseIterable, Identifiable {
case matchMenuBar = 1
case custom = 2
var id: Int { rawValue }
var label: String {
switch self {
case .matchMenuBar: return "Match Menu Bar"
case .custom: return "Custom"
}
}
}
struct TerminalSizePreset: Codable, Equatable, Identifiable {
var id: UUID
var name: String
var width: Double
var height: Double
var hotkey: HotkeyBinding?
init(
id: UUID = UUID(),
name: String,
width: Double,
height: Double,
hotkey: HotkeyBinding? = nil
) {
self.id = id
self.name = name
self.width = width
self.height = height
self.hotkey = hotkey
}
var size: CGSize {
CGSize(width: width, height: height)
}
}
enum TerminalSizePresetStore {
static func load() -> [TerminalSizePreset] {
let defaults = UserDefaults.standard
guard let json = defaults.string(forKey: NotchSettings.Keys.terminalSizePresets),
let presets = decodePresets(from: json) else {
return defaultPresets()
}
return presets
}
static func save(_ presets: [TerminalSizePreset]) {
UserDefaults.standard.set(encodePresets(presets), forKey: NotchSettings.Keys.terminalSizePresets)
}
static func reset() {
save(defaultPresets())
}
static func loadDefaults() -> [TerminalSizePreset] {
defaultPresets()
}
static func defaultPresetsJSON() -> String {
encodePresets(defaultPresets())
}
static func suggestedHotkey(for presets: [TerminalSizePreset]) -> HotkeyBinding? {
let used = Set(presets.compactMap(\.hotkey))
for digit in 1...9 {
guard let candidate = HotkeyBinding.cmdShiftDigit(digit) else { continue }
if !used.contains(candidate) {
return candidate
}
}
return nil
}
private static func defaultPresets() -> [TerminalSizePreset] {
[
TerminalSizePreset(name: "Compact", width: 480, height: 220, hotkey: HotkeyBinding.cmdShiftDigit(1)),
TerminalSizePreset(name: "Default", width: 640, height: 350, hotkey: HotkeyBinding.cmdShiftDigit(2)),
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

@@ -0,0 +1,10 @@
import Foundation
/// Represents the two visual states of the notch overlay.
enum NotchState: String {
/// Compact bar matching the physical notch or menu bar height.
case closed
/// Expanded panel showing content (plain black for now).
case open
}

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

@@ -0,0 +1,75 @@
import SwiftUI
import Combine
/// Compatibility adapter for the legacy single-workspace architecture.
/// New code should use `WorkspaceRegistry` + `WorkspaceController`.
@MainActor
class TerminalManager: ObservableObject {
static let shared = TerminalManager()
private var workspaceCancellable: AnyCancellable?
private init() {
workspaceCancellable = WorkspaceRegistry.shared.defaultWorkspaceController.objectWillChange
.sink { [weak self] _ in
self?.objectWillChange.send()
}
}
private var workspace: WorkspaceController {
WorkspaceRegistry.shared.defaultWorkspaceController
}
var tabs: [TerminalSession] {
workspace.tabs
}
var activeTabIndex: Int {
workspace.activeTabIndex
}
var activeTab: TerminalSession? {
workspace.activeTab
}
var activeTitle: String {
workspace.activeTitle
}
func newTab() {
workspace.newTab()
}
func closeActiveTab() {
workspace.closeActiveTab()
}
func closeTab(at index: Int) {
workspace.closeTab(at: index)
}
func switchToTab(at index: Int) {
workspace.switchToTab(at: index)
}
func nextTab() {
workspace.nextTab()
}
func previousTab() {
workspace.previousTab()
}
func detachActiveTab() -> TerminalSession? {
workspace.detachActiveTab()
}
func updateAllFontSizes(_ size: CGFloat) {
workspace.updateAllFontSizes(size)
}
func updateAllThemes(_ theme: TerminalTheme) {
workspace.updateAllThemes(theme)
}
}

View File

@@ -0,0 +1,165 @@
import AppKit
import SwiftTerm
import Combine
/// Wraps a single SwiftTerm TerminalView + LocalProcess pair.
@MainActor
class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, @preconcurrency TerminalViewDelegate {
let id = UUID()
let terminalView: TerminalView
private var process: LocalProcess?
private var keyEventMonitor: Any?
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, shellPath: String) {
terminalView = TerminalView(frame: NSRect(x: 0, y: 0, width: 600, height: 300))
configuredShellPath = shellPath
super.init()
terminalView.terminalDelegate = self
let font = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular)
terminalView.font = font
applyTheme(theme)
installCommandArrowMonitor()
startShell()
}
deinit {
if let keyEventMonitor {
NSEvent.removeMonitor(keyEventMonitor)
}
}
// MARK: - Shell management
private func startShell() {
let shellPath = resolveShell()
let shellName = (shellPath as NSString).lastPathComponent
let loginExecName = "-\(shellName)"
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,
currentDirectory: NSHomeDirectory()
)
process = proc
title = shellName
}
private func resolveShell() -> String {
let custom = configuredShellPath.trimmingCharacters(in: .whitespacesAndNewlines)
if !custom.isEmpty && FileManager.default.isExecutableFile(atPath: custom) {
return custom
}
return ProcessInfo.processInfo.environment["SHELL"] ?? "/bin/zsh"
}
private func installCommandArrowMonitor() {
keyEventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
guard let self else { return event }
guard let window = self.terminalView.window else { return event }
guard event.window === window else { return event }
guard window.firstResponder === self.terminalView else { return event }
guard let sequence = TerminalCommandArrowBehavior.sequence(
for: event.modifierFlags,
keyCode: event.keyCode,
applicationCursor: self.terminalView.getTerminal().applicationCursor
) else {
return event
}
self.terminalView.send(data: sequence[...])
return nil
}
}
func updateFontSize(_ size: CGFloat) {
terminalView.font = NSFont.monospacedSystemFont(ofSize: size, weight: .regular)
}
func applyTheme(_ theme: TerminalTheme) {
// Keep the notch visually consistent while swapping the terminal's
// default foreground color and ANSI palette for command output.
terminalView.nativeBackgroundColor = backgroundColor
terminalView.nativeForegroundColor = theme.foregroundColor
terminalView.installColors(theme.ansiColors)
}
func terminate() {
process?.terminate()
process = nil
isRunning = false
}
// MARK: - LocalProcessDelegate
nonisolated func processTerminated(_ source: LocalProcess, exitCode: Int32?) {
Task { @MainActor in self.isRunning = false }
}
nonisolated func dataReceived(slice: ArraySlice<UInt8>) {
let data = slice
Task { @MainActor in self.terminalView.feed(byteArray: data) }
}
nonisolated func getWindowSize() -> winsize {
var ws = winsize()
ws.ws_col = 80
ws.ws_row = 24
return ws
}
// MARK: - TerminalViewDelegate
func send(source: TerminalView, data: ArraySlice<UInt8>) {
process?.send(data: data)
}
func setTerminalTitle(source: TerminalView, title: String) {
self.title = title.isEmpty ? "shell" : title
}
func sizeChanged(source: TerminalView, newCols: Int, newRows: Int) {
guard newCols > 0, newRows > 0 else { return }
guard let proc = process else { return }
let fd = proc.childfd
guard fd >= 0 else { return }
var ws = winsize()
ws.ws_col = UInt16(newCols)
ws.ws_row = UInt16(newRows)
_ = ioctl(fd, TIOCSWINSZ, &ws)
}
func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {
currentDirectory = directory
}
func scrolled(source: TerminalView, position: Double) {}
func rangeChanged(source: TerminalView, startY: Int, endY: Int) {}
func clipboardCopy(source: TerminalView, content: Data) {
NSPasteboard.general.clearContents()
NSPasteboard.general.setData(content, forType: .string)
}
func requestOpenLink(source: TerminalView, link: String, params: [String : String]) {
if let url = URL(string: link) { NSWorkspace.shared.open(url) }
}
func bell(source: TerminalView) { NSSound.beep() }
func iTermContent(source: TerminalView, content: ArraySlice<UInt8>) {}
}

View File

@@ -0,0 +1,117 @@
import AppKit
import SwiftTerm
enum TerminalTheme: String, CaseIterable, Identifiable {
case terminalApp
case xterm
case solarizedDark
case dracula
case nord
var id: String { rawValue }
var label: String {
switch self {
case .terminalApp: return "Classic"
case .xterm: return "Xterm"
case .solarizedDark:return "Solarized Dark"
case .dracula: return "Dracula"
case .nord: return "Nord"
}
}
var detail: String {
switch self {
case .terminalApp:
return "Matches the app's current terminal palette."
case .xterm:
return "Traditional xterm-style ANSI colors."
case .solarizedDark:
return "Low-contrast dark palette with Solarized accents."
case .dracula:
return "Higher-contrast dark palette with vivid ANSI colors."
case .nord:
return "Cool blue-grey palette with restrained accents."
}
}
var foregroundColor: NSColor {
switch self {
case .terminalApp:
return Self.nsColor(0xE5E5E5)
case .xterm:
return Self.nsColor(0xE5E5E5)
case .solarizedDark:
return Self.nsColor(0x839496)
case .dracula:
return Self.nsColor(0xF8F8F2)
case .nord:
return Self.nsColor(0xD8DEE9)
}
}
var ansiColors: [Color] {
switch self {
case .terminalApp:
return Self.palette([
0x000000, 0xC23621, 0x25BC24, 0xADAD27,
0x492EE1, 0xD338D3, 0x33BBC8, 0xCBCCCD,
0x818383, 0xFC391F, 0x31E722, 0xEAEC23,
0x5833FF, 0xF935F8, 0x14F0F0, 0xE9EBEB
])
case .xterm:
return Self.palette([
0x000000, 0xCD0000, 0x00CD00, 0xCDCD00,
0x0000EE, 0xCD00CD, 0x00CDCD, 0xE5E5E5,
0x7F7F7F, 0xFF0000, 0x00FF00, 0xFFFF00,
0x5C5CFF, 0xFF00FF, 0x00FFFF, 0xFFFFFF
])
case .solarizedDark:
return Self.palette([
0x073642, 0xDC322F, 0x859900, 0xB58900,
0x268BD2, 0xD33682, 0x2AA198, 0xEEE8D5,
0x002B36, 0xCB4B16, 0x586E75, 0x657B83,
0x839496, 0x6C71C4, 0x93A1A1, 0xFDF6E3
])
case .dracula:
return Self.palette([
0x21222C, 0xFF5555, 0x50FA7B, 0xF1FA8C,
0xBD93F9, 0xFF79C6, 0x8BE9FD, 0xF8F8F2,
0x6272A4, 0xFF6E6E, 0x69FF94, 0xFFFFA5,
0xD6ACFF, 0xFF92DF, 0xA4FFFF, 0xFFFFFF
])
case .nord:
return Self.palette([
0x3B4252, 0xBF616A, 0xA3BE8C, 0xEBCB8B,
0x81A1C1, 0xB48EAD, 0x88C0D0, 0xE5E9F0,
0x4C566A, 0xBF616A, 0xA3BE8C, 0xEBCB8B,
0x81A1C1, 0xB48EAD, 0x8FBCBB, 0xECEFF4
])
}
}
static func resolve(_ rawValue: String) -> TerminalTheme {
TerminalTheme(rawValue: rawValue) ?? .terminalApp
}
private static func palette(_ hexValues: [UInt32]) -> [Color] {
hexValues.map(terminalColor)
}
private static func terminalColor(_ hex: UInt32) -> Color {
Color(
red: UInt16(((hex >> 16) & 0xFF) * 257),
green: UInt16(((hex >> 8) & 0xFF) * 257),
blue: UInt16((hex & 0xFF) * 257)
)
}
private static func nsColor(_ hex: UInt32) -> NSColor {
NSColor(
deviceRed: CGFloat((hex >> 16) & 0xFF) / 255.0,
green: CGFloat((hex >> 8) & 0xFF) / 255.0,
blue: CGFloat(hex & 0xFF) / 255.0,
alpha: 1.0
)
}
}

View File

@@ -0,0 +1,174 @@
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 hotkey: HotkeyBinding?
@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.hotkey = summary.hotkey
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, hotkey: hotkey)
}
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 updateHotkey(_ updatedHotkey: HotkeyBinding?) {
guard hotkey != updatedHotkey else { return }
hotkey = updatedHotkey
}
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,181 @@
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()
}
func updateWorkspaceHotkey(id: WorkspaceID, to hotkey: HotkeyBinding?) {
guard let index = workspaceSummaries.firstIndex(where: { $0.id == id }) else { return }
guard workspaceSummaries[index].hotkey != hotkey else { return }
workspaceSummaries[index].hotkey = hotkey
controllers[id]?.updateHotkey(hotkey)
persistWorkspaceSummaries()
}
func nextWorkspaceID(after id: WorkspaceID) -> WorkspaceID? {
guard !workspaceSummaries.isEmpty else { return nil }
guard let index = workspaceSummaries.firstIndex(where: { $0.id == id }) else {
return workspaceSummaries.first?.id
}
let nextIndex = workspaceSummaries.index(after: index)
return workspaceSummaries[nextIndex == workspaceSummaries.endIndex ? workspaceSummaries.startIndex : nextIndex].id
}
func previousWorkspaceID(before id: WorkspaceID) -> WorkspaceID? {
guard !workspaceSummaries.isEmpty else { return nil }
guard let index = workspaceSummaries.firstIndex(where: { $0.id == id }) else {
return workspaceSummaries.last?.id
}
let previousIndex = index == workspaceSummaries.startIndex
? workspaceSummaries.index(before: workspaceSummaries.endIndex)
: workspaceSummaries.index(before: index)
return workspaceSummaries[previousIndex].id
}
@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,29 @@
import Foundation
typealias WorkspaceID = UUID
struct WorkspaceSummary: Identifiable, Equatable, Codable {
var id: WorkspaceID
var name: String
var createdAt: Date
var hotkey: HotkeyBinding?
init(id: WorkspaceID = UUID(), name: String, createdAt: Date = Date(), hotkey: HotkeyBinding? = nil) {
self.id = id
self.name = name
self.createdAt = createdAt
self.hotkey = hotkey
}
}
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?
}