Yep. AI rewrote the whole thing.
This commit is contained in:
161
Downterm/CommandNotch/Models/AppSettings.swift
Normal file
161
Downterm/CommandNotch/Models/AppSettings.swift
Normal 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 }
|
||||
}
|
||||
74
Downterm/CommandNotch/Models/AppSettingsController.swift
Normal file
74
Downterm/CommandNotch/Models/AppSettingsController.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
136
Downterm/CommandNotch/Models/AppSettingsStore.swift
Normal file
136
Downterm/CommandNotch/Models/AppSettingsStore.swift
Normal 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
|
||||
}
|
||||
}
|
||||
189
Downterm/CommandNotch/Models/NotchOrchestrator.swift
Normal file
189
Downterm/CommandNotch/Models/NotchOrchestrator.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
222
Downterm/CommandNotch/Models/ScreenContext.swift
Normal file
222
Downterm/CommandNotch/Models/ScreenContext.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
268
Downterm/CommandNotch/Models/ScreenRegistry.swift
Normal file
268
Downterm/CommandNotch/Models/ScreenRegistry.swift
Normal 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 {}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
167
Downterm/CommandNotch/Models/WorkspaceController.swift
Normal file
167
Downterm/CommandNotch/Models/WorkspaceController.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
150
Downterm/CommandNotch/Models/WorkspaceRegistry.swift
Normal file
150
Downterm/CommandNotch/Models/WorkspaceRegistry.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
59
Downterm/CommandNotch/Models/WorkspaceStore.swift
Normal file
59
Downterm/CommandNotch/Models/WorkspaceStore.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
27
Downterm/CommandNotch/Models/WorkspaceSummary.swift
Normal file
27
Downterm/CommandNotch/Models/WorkspaceSummary.swift
Normal 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?
|
||||
}
|
||||
Reference in New Issue
Block a user