Initial commit

This commit is contained in:
2026-02-27 11:57:09 +11:00
commit 4070904db8
29 changed files with 2994 additions and 0 deletions

View File

@@ -0,0 +1,444 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
2213F430F3D8A88033607CD2 /* NotchSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA6359CF9DDF89413440300D /* NotchSettings.swift */; };
247C6F84E7ADE7AED43381E2 /* DowntermApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B671125208055E5334CB85E /* DowntermApp.swift */; };
26A767A10DDA77A690CC3C37 /* NotchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 589421631401C819FE1A7BA9 /* NotchViewModel.swift */; };
295653929D5B9C0E6C90D6D7 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 032AECA58EA4C274BE9F3320 /* SwiftTerm */; };
37FC0A7CEEA37C9DCC6A8351 /* TerminalSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B598809B19C892470DE7268 /* TerminalSession.swift */; };
4566E6B87CB62AF5C8D4B9D8 /* NotchShape.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FC09C538CBE7C2D072008B2 /* NotchShape.swift */; };
4D5125E11B4DDBDB3DFACBAF /* HotkeyManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B72743F178231E0B06DD3DE /* HotkeyManager.swift */; };
5B14FC23928E817FEB8D2A74 /* NotchWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02FEFF9074A85F02C43D9408 /* NotchWindow.swift */; };
7BD705CA6A34117929B362EC /* HotkeyRecorderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 490C53139360D970099D8F3D /* HotkeyRecorderView.swift */; };
7DE94234DC42EAB79896E176 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5CB3313B230019D0E988AFE /* SettingsView.swift */; };
7EA51C3720BED7E6189E057D /* TabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = F009B75D078A5070B5EA9738 /* TabBar.swift */; };
81A912E3E16165D999882078 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C0779490DE9020FBBC464BE /* AppDelegate.swift */; };
888C45C650327089EBD39B2E /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20BA7F4716DA3909DA8BC381 /* ContentView.swift */; };
88EBFBB2292659EA7C42A8F9 /* HotkeyBinding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BB81B6DA7126E1F5FBCC8B8 /* HotkeyBinding.swift */; };
8FC731CF99AB4C1C10C16FAB /* PopoutWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1CD1DE020F0467AFB98DE3 /* PopoutWindowController.swift */; };
A70FDB7EEEB895D475ED96E8 /* NotchState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C5C99B7CD7F60E55844E40C /* NotchState.swift */; };
BE5E64222CF5689AC7088683 /* ScreenManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15A290D4D21D6C01A583A372 /* ScreenManager.swift */; };
C4C93F2911B41BC19A2AE934 /* SettingsWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A973877BCE6084D0EBBBDBD /* SettingsWindowController.swift */; };
CC26C1677258E44F0D7B106A /* SwiftTermView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E47000112562615C7E59489 /* SwiftTermView.swift */; };
E9A064422790735E033E534F /* TerminalManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA6843B571B41986DE386F5F /* TerminalManager.swift */; };
F0130A88D1453CD199FA65D7 /* NSScreen+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0CED6A0F25A6E57D8AA308A /* NSScreen+Extensions.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
02FEFF9074A85F02C43D9408 /* NotchWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchWindow.swift; sourceTree = "<group>"; };
0A973877BCE6084D0EBBBDBD /* SettingsWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsWindowController.swift; sourceTree = "<group>"; };
15A290D4D21D6C01A583A372 /* ScreenManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenManager.swift; sourceTree = "<group>"; };
1E47000112562615C7E59489 /* SwiftTermView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftTermView.swift; sourceTree = "<group>"; };
1FC09C538CBE7C2D072008B2 /* NotchShape.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchShape.swift; sourceTree = "<group>"; };
20BA7F4716DA3909DA8BC381 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
2C5C99B7CD7F60E55844E40C /* NotchState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchState.swift; sourceTree = "<group>"; };
3B72743F178231E0B06DD3DE /* HotkeyManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotkeyManager.swift; sourceTree = "<group>"; };
490C53139360D970099D8F3D /* HotkeyRecorderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotkeyRecorderView.swift; sourceTree = "<group>"; };
4B671125208055E5334CB85E /* DowntermApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DowntermApp.swift; sourceTree = "<group>"; };
4BB81B6DA7126E1F5FBCC8B8 /* HotkeyBinding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotkeyBinding.swift; sourceTree = "<group>"; };
589421631401C819FE1A7BA9 /* NotchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchViewModel.swift; sourceTree = "<group>"; };
5C0779490DE9020FBBC464BE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
665CFC051CF185B71199608D /* Downterm.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Downterm.app; sourceTree = BUILT_PRODUCTS_DIR; };
7B598809B19C892470DE7268 /* TerminalSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSession.swift; sourceTree = "<group>"; };
9547A79F60E46F4521A70674 /* Downterm.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Downterm.entitlements; sourceTree = "<group>"; };
AA6359CF9DDF89413440300D /* NotchSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchSettings.swift; sourceTree = "<group>"; };
BA6843B571B41986DE386F5F /* TerminalManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalManager.swift; sourceTree = "<group>"; };
C5CB3313B230019D0E988AFE /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
EA1CD1DE020F0467AFB98DE3 /* PopoutWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopoutWindowController.swift; sourceTree = "<group>"; };
F009B75D078A5070B5EA9738 /* TabBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBar.swift; sourceTree = "<group>"; };
F0CED6A0F25A6E57D8AA308A /* NSScreen+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSScreen+Extensions.swift"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
6085DF2BDFFB2A99C4ABD514 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
295653929D5B9C0E6C90D6D7 /* SwiftTerm in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
0EF94ED46B4860C241540F0A /* Resources */ = {
isa = PBXGroup;
children = (
9547A79F60E46F4521A70674 /* Downterm.entitlements */,
);
path = Resources;
sourceTree = "<group>";
};
27C90448ECAC906F0DA429C0 /* Managers */ = {
isa = PBXGroup;
children = (
3B72743F178231E0B06DD3DE /* HotkeyManager.swift */,
EA1CD1DE020F0467AFB98DE3 /* PopoutWindowController.swift */,
15A290D4D21D6C01A583A372 /* ScreenManager.swift */,
0A973877BCE6084D0EBBBDBD /* SettingsWindowController.swift */,
);
path = Managers;
sourceTree = "<group>";
};
792DD4F8C079680683D8FF7A /* Products */ = {
isa = PBXGroup;
children = (
665CFC051CF185B71199608D /* Downterm.app */,
);
name = Products;
sourceTree = "<group>";
};
869AD33E1CDEB9CBAD401BA6 /* Models */ = {
isa = PBXGroup;
children = (
4BB81B6DA7126E1F5FBCC8B8 /* HotkeyBinding.swift */,
AA6359CF9DDF89413440300D /* NotchSettings.swift */,
2C5C99B7CD7F60E55844E40C /* NotchState.swift */,
589421631401C819FE1A7BA9 /* NotchViewModel.swift */,
BA6843B571B41986DE386F5F /* TerminalManager.swift */,
7B598809B19C892470DE7268 /* TerminalSession.swift */,
);
path = Models;
sourceTree = "<group>";
};
8D95E0324E6AFC9E4DC0C087 /* Extensions */ = {
isa = PBXGroup;
children = (
F0CED6A0F25A6E57D8AA308A /* NSScreen+Extensions.swift */,
);
path = Extensions;
sourceTree = "<group>";
};
9E1CA4816F67033BBD52D8A3 /* Downterm */ = {
isa = PBXGroup;
children = (
5C0779490DE9020FBBC464BE /* AppDelegate.swift */,
20BA7F4716DA3909DA8BC381 /* ContentView.swift */,
4B671125208055E5334CB85E /* DowntermApp.swift */,
F32F526005A2589010E63C76 /* Components */,
8D95E0324E6AFC9E4DC0C087 /* Extensions */,
27C90448ECAC906F0DA429C0 /* Managers */,
869AD33E1CDEB9CBAD401BA6 /* Models */,
0EF94ED46B4860C241540F0A /* Resources */,
C2B8955F4D0A1DAA7E60326A /* Views */,
);
path = Downterm;
sourceTree = "<group>";
};
C2B8955F4D0A1DAA7E60326A /* Views */ = {
isa = PBXGroup;
children = (
C5CB3313B230019D0E988AFE /* SettingsView.swift */,
);
path = Views;
sourceTree = "<group>";
};
F32F526005A2589010E63C76 /* Components */ = {
isa = PBXGroup;
children = (
490C53139360D970099D8F3D /* HotkeyRecorderView.swift */,
1FC09C538CBE7C2D072008B2 /* NotchShape.swift */,
02FEFF9074A85F02C43D9408 /* NotchWindow.swift */,
1E47000112562615C7E59489 /* SwiftTermView.swift */,
F009B75D078A5070B5EA9738 /* TabBar.swift */,
);
path = Components;
sourceTree = "<group>";
};
FC6F23514BFE2235BD4154E8 = {
isa = PBXGroup;
children = (
9E1CA4816F67033BBD52D8A3 /* Downterm */,
792DD4F8C079680683D8FF7A /* Products */,
);
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
1485207FA11756EC2DF4F08B /* Downterm */ = {
isa = PBXNativeTarget;
buildConfigurationList = 74CB98309F5464CDCB00C63A /* Build configuration list for PBXNativeTarget "Downterm" */;
buildPhases = (
F3C6D5CD1247D246A3F6F7AB /* Sources */,
6085DF2BDFFB2A99C4ABD514 /* Frameworks */,
);
buildRules = (
);
dependencies = (
);
name = Downterm;
packageProductDependencies = (
032AECA58EA4C274BE9F3320 /* SwiftTerm */,
);
productName = Downterm;
productReference = 665CFC051CF185B71199608D /* Downterm.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
F72A983360EF3F99042A4895 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1600;
};
buildConfigurationList = D1C4019FEAFC83BB053C9E6E /* Build configuration list for PBXProject "Downterm" */;
compatibilityVersion = "Xcode 14.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
Base,
en,
);
mainGroup = FC6F23514BFE2235BD4154E8;
minimizedProjectReferenceProxies = 1;
packageReferences = (
80F03B77566BF59C9941EAD4 /* XCRemoteSwiftPackageReference "SwiftTerm" */,
);
preferredProjectObjectVersion = 77;
projectDirPath = "";
projectRoot = "";
targets = (
1485207FA11756EC2DF4F08B /* Downterm */,
);
};
/* End PBXProject section */
/* Begin PBXSourcesBuildPhase section */
F3C6D5CD1247D246A3F6F7AB /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
81A912E3E16165D999882078 /* AppDelegate.swift in Sources */,
888C45C650327089EBD39B2E /* ContentView.swift in Sources */,
247C6F84E7ADE7AED43381E2 /* DowntermApp.swift in Sources */,
88EBFBB2292659EA7C42A8F9 /* HotkeyBinding.swift in Sources */,
4D5125E11B4DDBDB3DFACBAF /* HotkeyManager.swift in Sources */,
7BD705CA6A34117929B362EC /* HotkeyRecorderView.swift in Sources */,
F0130A88D1453CD199FA65D7 /* NSScreen+Extensions.swift in Sources */,
2213F430F3D8A88033607CD2 /* NotchSettings.swift in Sources */,
4566E6B87CB62AF5C8D4B9D8 /* NotchShape.swift in Sources */,
A70FDB7EEEB895D475ED96E8 /* NotchState.swift in Sources */,
26A767A10DDA77A690CC3C37 /* NotchViewModel.swift in Sources */,
5B14FC23928E817FEB8D2A74 /* NotchWindow.swift in Sources */,
8FC731CF99AB4C1C10C16FAB /* PopoutWindowController.swift in Sources */,
BE5E64222CF5689AC7088683 /* ScreenManager.swift in Sources */,
7DE94234DC42EAB79896E176 /* SettingsView.swift in Sources */,
C4C93F2911B41BC19A2AE934 /* SettingsWindowController.swift in Sources */,
CC26C1677258E44F0D7B106A /* SwiftTermView.swift in Sources */,
7EA51C3720BED7E6189E057D /* TabBar.swift in Sources */,
E9A064422790735E033E534F /* TerminalManager.swift in Sources */,
37FC0A7CEEA37C9DCC6A8351 /* TerminalSession.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
0B8C784EF064E46C44076D6B /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = Downterm/Resources/Downterm.entitlements;
COMBINE_HIDPI_IMAGES = YES;
INFOPLIST_FILE = Downterm/Resources/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.downterm.app;
PRODUCT_NAME = Downterm;
SDKROOT = macosx;
};
name = Release;
};
3595A9212275B9AEC4448C64 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"$(inherited)",
"DEBUG=1",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = macosx;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.10;
};
name = Debug;
};
7020C02C1BDF63690CC9A3AC /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = Downterm/Resources/Downterm.entitlements;
COMBINE_HIDPI_IMAGES = YES;
INFOPLIST_FILE = Downterm/Resources/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.downterm.app;
PRODUCT_NAME = Downterm;
SDKROOT = macosx;
};
name = Debug;
};
BC741C4C821EA399B645E547 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = 5.10;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
74CB98309F5464CDCB00C63A /* Build configuration list for PBXNativeTarget "Downterm" */ = {
isa = XCConfigurationList;
buildConfigurations = (
7020C02C1BDF63690CC9A3AC /* Debug */,
0B8C784EF064E46C44076D6B /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
D1C4019FEAFC83BB053C9E6E /* Build configuration list for PBXProject "Downterm" */ = {
isa = XCConfigurationList;
buildConfigurations = (
3595A9212275B9AEC4448C64 /* Debug */,
BC741C4C821EA399B645E547 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
80F03B77566BF59C9941EAD4 /* XCRemoteSwiftPackageReference "SwiftTerm" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/migueldeicaza/SwiftTerm.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 1.2.0;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
032AECA58EA4C274BE9F3320 /* SwiftTerm */ = {
isa = XCSwiftPackageProductDependency;
package = 80F03B77566BF59C9941EAD4 /* XCRemoteSwiftPackageReference "SwiftTerm" */;
productName = SwiftTerm;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = F72A983360EF3F99042A4895 /* Project object */;
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,24 @@
{
"originHash" : "34fc6ded3af11d97770b2e20b5e3cfd72f9d3309ae17ef3278b95041f16c02dc",
"pins" : [
{
"identity" : "swift-argument-parser",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-argument-parser",
"state" : {
"revision" : "c5d11a805e765f52ba34ec7284bd4fcd6ba68615",
"version" : "1.7.0"
}
},
{
"identity" : "swiftterm",
"kind" : "remoteSourceControl",
"location" : "https://github.com/migueldeicaza/SwiftTerm.git",
"state" : {
"revision" : "b1262db5b6bea699a8260a8c66999436c508ca56",
"version" : "1.11.2"
}
}
],
"version" : 3
}

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SchemeUserState</key>
<dict>
<key>Downterm.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,69 @@
import AppKit
import Combine
/// Application delegate that bootstraps the notch overlay system.
@MainActor
class AppDelegate: NSObject, NSApplicationDelegate {
private var cancellables = Set<AnyCancellable>()
func applicationDidFinishLaunching(_ notification: Notification) {
NotchSettings.registerDefaults()
NSApp.setActivationPolicy(.accessory)
ScreenManager.shared.start()
observeDisplayPreference()
observeSizePreferences()
observeFontSizeChanges()
}
func applicationWillTerminate(_ notification: Notification) {
ScreenManager.shared.stop()
}
// MARK: - Preference observers
/// Only rebuild windows when the display-count preference changes.
private func observeDisplayPreference() {
UserDefaults.standard.publisher(for: \.showOnAllDisplays)
.removeDuplicates()
.dropFirst()
.sink { _ in
ScreenManager.shared.rebuildWindows()
}
.store(in: &cancellables)
}
/// Reposition (not rebuild) when any sizing preference changes.
private func observeSizePreferences() {
NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)
.debounce(for: .milliseconds(300), scheduler: RunLoop.main)
.sink { _ in
ScreenManager.shared.repositionWindows()
}
.store(in: &cancellables)
}
/// Live-update terminal font size across all sessions.
private func observeFontSizeChanges() {
UserDefaults.standard.publisher(for: \.terminalFontSize)
.removeDuplicates()
.sink { newSize in
guard newSize > 0 else { return }
TerminalManager.shared.updateAllFontSizes(CGFloat(newSize))
}
.store(in: &cancellables)
}
}
// MARK: - KVO key paths
private extension UserDefaults {
@objc var terminalFontSize: Double {
double(forKey: NotchSettings.Keys.terminalFontSize)
}
@objc var showOnAllDisplays: Bool {
bool(forKey: NotchSettings.Keys.showOnAllDisplays)
}
}

View File

@@ -0,0 +1,111 @@
import SwiftUI
import AppKit
/// A clickable field that records a keyboard shortcut when focused.
/// Click it, press a key combination, and it saves the binding.
struct HotkeyRecorderView: View {
let label: String
@Binding var binding: HotkeyBinding
@State private var isRecording = false
var body: some View {
HStack {
Text(label)
.frame(width: 140, alignment: .leading)
HotkeyRecorderField(binding: $binding, isRecording: $isRecording)
.frame(width: 120, height: 24)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(isRecording ? Color.accentColor.opacity(0.15) : Color.secondary.opacity(0.1))
)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(isRecording ? Color.accentColor : Color.secondary.opacity(0.3), lineWidth: 1)
)
}
}
}
/// NSViewRepresentable that captures key events when focused.
struct HotkeyRecorderField: NSViewRepresentable {
@Binding var binding: HotkeyBinding
@Binding var isRecording: Bool
func makeNSView(context: Context) -> HotkeyNSView {
let view = HotkeyNSView()
view.onKeyRecorded = { newBinding in
binding = newBinding
isRecording = false
}
view.onFocusChanged = { focused in
isRecording = focused
}
return view
}
func updateNSView(_ nsView: HotkeyNSView, context: Context) {
nsView.currentLabel = binding.displayString
nsView.showRecording = isRecording
nsView.needsDisplay = true
}
}
/// The actual NSView that handles key capture.
class HotkeyNSView: NSView {
var currentLabel: String = ""
var showRecording: Bool = false
var onKeyRecorded: ((HotkeyBinding) -> Void)?
var onFocusChanged: ((Bool) -> Void)?
override var acceptsFirstResponder: Bool { true }
override func draw(_ dirtyRect: NSRect) {
let text = showRecording ? "Press keys…" : currentLabel
let attrs: [NSAttributedString.Key: Any] = [
.font: NSFont.monospacedSystemFont(ofSize: 12, weight: .medium),
.foregroundColor: showRecording ? NSColor.controlAccentColor : NSColor.labelColor
]
let str = NSAttributedString(string: text, attributes: attrs)
let size = str.size()
let point = NSPoint(
x: (bounds.width - size.width) / 2,
y: (bounds.height - size.height) / 2
)
str.draw(at: point)
}
override func mouseDown(with event: NSEvent) {
window?.makeFirstResponder(self)
}
override func becomeFirstResponder() -> Bool {
onFocusChanged?(true)
return true
}
override func resignFirstResponder() -> Bool {
onFocusChanged?(false)
return true
}
override func keyDown(with event: NSEvent) {
guard showRecording else {
super.keyDown(with: event)
return
}
let relevantFlags: NSEvent.ModifierFlags = [.command, .shift, .control, .option]
let masked = event.modifierFlags.intersection(.deviceIndependentFlagsMask).intersection(relevantFlags)
// Require at least one modifier key
guard !masked.isEmpty else { return }
let binding = HotkeyBinding(modifiers: masked.rawValue, keyCode: event.keyCode)
onKeyRecorded?(binding)
// Resign first responder after recording
window?.makeFirstResponder(nil)
}
}

View File

@@ -0,0 +1,109 @@
import SwiftUI
/// Custom SwiftUI Shape that draws the characteristic MacBook notch outline.
/// Both top and bottom corner radii are animatable, enabling smooth transitions
/// between the compact closed state and the expanded open state.
///
/// The shape uses quadratic Bezier curves to produce the distinctive
/// "ear" ramps on each side when closed, and a clean rounded-bottom
/// rectangle when open (topCornerRadius approaches 0).
struct NotchShape: Shape {
/// Radius applied to the top-left and top-right outer corners (the "ears").
/// When close to 0, the top corners become sharp and the shape is a
/// rectangle with rounded bottom corners no visible ear ramps.
var topCornerRadius: CGFloat
/// Radius applied to the bottom-left and bottom-right inner corners.
var bottomCornerRadius: CGFloat
// MARK: - Animatable conformance
var animatableData: AnimatablePair<CGFloat, CGFloat> {
get { AnimatablePair(topCornerRadius, bottomCornerRadius) }
set {
topCornerRadius = newValue.first
bottomCornerRadius = newValue.second
}
}
// MARK: - Path
func path(in rect: CGRect) -> Path {
var path = Path()
let minX = rect.minX
let maxX = rect.maxX
let minY = rect.minY
let maxY = rect.maxY
let width = rect.width
let height = rect.height
let topR = min(topCornerRadius, width / 4, height / 2)
let botR = min(bottomCornerRadius, width / 4, height / 2)
// Start at the top-left corner of the rect
path.move(to: CGPoint(x: minX, y: minY))
if topR > 0.5 {
// Top-left ear: curve down from the top edge
path.addQuadCurve(
to: CGPoint(x: minX + topR, y: minY + topR),
control: CGPoint(x: minX, y: minY + topR)
)
} else {
path.addLine(to: CGPoint(x: minX, y: minY))
}
// Left edge down to bottom-left corner area
path.addLine(to: CGPoint(x: minX + topR, y: maxY - botR))
// Bottom-left inner corner
path.addQuadCurve(
to: CGPoint(x: minX + topR + botR, y: maxY),
control: CGPoint(x: minX + topR, y: maxY)
)
// Bottom edge across
path.addLine(to: CGPoint(x: maxX - topR - botR, y: maxY))
// Bottom-right inner corner
path.addQuadCurve(
to: CGPoint(x: maxX - topR, y: maxY - botR),
control: CGPoint(x: maxX - topR, y: maxY)
)
// Right edge up to top-right ear area
path.addLine(to: CGPoint(x: maxX - topR, y: minY + topR))
if topR > 0.5 {
// Top-right ear: curve back up to the top edge
path.addQuadCurve(
to: CGPoint(x: maxX, y: minY),
control: CGPoint(x: maxX, y: minY + topR)
)
} else {
path.addLine(to: CGPoint(x: maxX, y: minY))
}
path.closeSubpath()
return path
}
}
// MARK: - Convenience initializers
extension NotchShape {
/// Closed-state shape with tight corner radii that mimic the physical notch.
static var closed: NotchShape {
NotchShape(topCornerRadius: 6, bottomCornerRadius: 14)
}
/// Open-state shape: no ear ramps, just rounded bottom corners.
/// topCornerRadius is near-zero so the ears disappear and the panel
/// extends flush to the top edge of the screen.
static var opened: NotchShape {
NotchShape(topCornerRadius: 0, bottomCornerRadius: 24)
}
}

View File

@@ -0,0 +1,78 @@
import AppKit
import SwiftUI
/// Borderless, floating NSPanel that hosts the notch overlay.
/// When the notch is open the window accepts key status so the
/// terminal can receive keyboard input. On resignKey the
/// `onResignKey` closure fires to close the notch.
class NotchWindow: NSPanel {
var isNotchOpen: Bool = false
/// Called when the window loses key status while the notch is open.
var onResignKey: (() -> Void)?
override init(
contentRect: NSRect,
styleMask style: NSWindow.StyleMask,
backing backingStoreType: NSWindow.BackingStoreType,
defer flag: Bool
) {
// Start as a plain borderless utility panel.
// .nonactivatingPanel is NOT included so the window can
// properly accept key status when the notch opens.
super.init(
contentRect: contentRect,
styleMask: [.borderless, .utilityWindow, .nonactivatingPanel],
backing: .buffered,
defer: flag
)
configureWindow()
}
private func configureWindow() {
isOpaque = false
backgroundColor = .clear
isFloatingPanel = true
level = .mainMenu + 3
titleVisibility = .hidden
titlebarAppearsTransparent = true
hasShadow = false
isMovable = false
isMovableByWindowBackground = false
collectionBehavior = [
.canJoinAllSpaces,
.stationary,
.fullScreenAuxiliary,
.ignoresCycle
]
appearance = NSAppearance(named: .darkAqua)
// Accepts mouse events when the app is NOT active so the
// user can click the closed notch to open it.
acceptsMouseMovedEvents = true
}
// MARK: - Key window management
override var canBecomeKey: Bool { isNotchOpen }
override var canBecomeMain: Bool { false }
override func resignKey() {
super.resignKey()
if isNotchOpen {
// Brief async dispatch so the new key window settles first
// avoids closing when we're just transferring focus between
// our own windows (e.g. opening settings).
DispatchQueue.main.async { [weak self] in
guard let self, self.isNotchOpen else { return }
self.onResignKey?()
}
}
}
}

View File

@@ -0,0 +1,46 @@
import SwiftUI
import SwiftTerm
/// NSViewRepresentable wrapper that embeds a SwiftTerm TerminalView.
/// The container has a solid black background matching the notch panel.
/// All transparency is handled by the single `.opacity()` on ContentView.
struct SwiftTermView: NSViewRepresentable {
let session: TerminalSession
func makeNSView(context: Context) -> NSView {
let container = NSView()
container.wantsLayer = true
container.layer?.backgroundColor = NSColor.black.cgColor
embedTerminalView(in: container)
return container
}
func updateNSView(_ nsView: NSView, context: Context) {
let tv = session.terminalView
if nsView.subviews.first !== tv {
nsView.subviews.forEach { $0.removeFromSuperview() }
embedTerminalView(in: nsView)
}
DispatchQueue.main.async {
if let window = nsView.window, window.isKeyWindow {
window.makeFirstResponder(tv)
}
}
}
private func embedTerminalView(in container: NSView) {
let tv = session.terminalView
tv.removeFromSuperview()
container.addSubview(tv)
tv.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
tv.topAnchor.constraint(equalTo: container.topAnchor),
tv.bottomAnchor.constraint(equalTo: container.bottomAnchor),
tv.leadingAnchor.constraint(equalTo: container.leadingAnchor),
tv.trailingAnchor.constraint(equalTo: container.trailingAnchor),
])
}
}

View File

@@ -0,0 +1,73 @@
import SwiftUI
/// Horizontal tab bar at the bottom of the open notch panel.
/// Solid black background to match the rest of the notch
/// the single `.opacity()` on ContentView handles transparency.
struct TabBar: View {
@ObservedObject var terminalManager: TerminalManager
var body: some View {
HStack(spacing: 0) {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 2) {
ForEach(Array(terminalManager.tabs.enumerated()), id: \.element.id) { index, tab in
tabButton(for: tab, at: index)
}
}
.padding(.horizontal, 4)
}
Spacer()
Button {
terminalManager.newTab()
} label: {
Image(systemName: "plus")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(.white.opacity(0.6))
}
.buttonStyle(.plain)
.padding(.horizontal, 8)
}
.frame(height: 28)
.background(.black)
}
@ViewBuilder
private func tabButton(for tab: TerminalSession, at index: Int) -> some View {
let isActive = index == terminalManager.activeTabIndex
HStack(spacing: 4) {
Text(abbreviateTitle(tab.title))
.font(.system(size: 11))
.lineLimit(1)
.foregroundStyle(isActive ? .white : .white.opacity(0.5))
if isActive && terminalManager.tabs.count > 1 {
Button {
terminalManager.closeTab(at: index)
} label: {
Image(systemName: "xmark")
.font(.system(size: 8, weight: .bold))
.foregroundStyle(.white.opacity(0.4))
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(isActive ? Color.white.opacity(0.12) : Color.clear)
)
.contentShape(Rectangle())
.onTapGesture {
terminalManager.switchToTab(at: index)
}
}
private func abbreviateTitle(_ title: String) -> String {
title.count <= 24 ? title : String(title.prefix(22)) + ""
}
}

View File

@@ -0,0 +1,166 @@
import SwiftUI
import SwiftTerm
/// Main view rendered inside each NotchWindow.
///
/// Opacity strategy: EVERY element has a solid black background.
/// A single `.opacity(notchOpacity)` is applied at the outermost
/// level so everything becomes uniformly transparent no double
/// layering, no mismatched areas.
struct ContentView: View {
@ObservedObject var vm: NotchViewModel
@ObservedObject var terminalManager: TerminalManager
// MARK: - Settings
@AppStorage(NotchSettings.Keys.openNotchOnHover) private var openNotchOnHover = NotchSettings.Defaults.openNotchOnHover
@AppStorage(NotchSettings.Keys.minimumHoverDuration) private var minimumHoverDuration = NotchSettings.Defaults.minimumHoverDuration
@AppStorage(NotchSettings.Keys.enableShadow) private var enableShadow = NotchSettings.Defaults.enableShadow
@AppStorage(NotchSettings.Keys.shadowRadius) private var shadowRadius = NotchSettings.Defaults.shadowRadius
@AppStorage(NotchSettings.Keys.shadowOpacity) private var shadowOpacity = NotchSettings.Defaults.shadowOpacity
@AppStorage(NotchSettings.Keys.cornerRadiusScaling) private var cornerRadiusScaling = NotchSettings.Defaults.cornerRadiusScaling
@AppStorage(NotchSettings.Keys.notchOpacity) private var notchOpacity = NotchSettings.Defaults.notchOpacity
@AppStorage(NotchSettings.Keys.blurRadius) private var blurRadius = NotchSettings.Defaults.blurRadius
@AppStorage(NotchSettings.Keys.hoverSpringResponse) private var hoverSpringResponse = NotchSettings.Defaults.hoverSpringResponse
@AppStorage(NotchSettings.Keys.hoverSpringDamping) private var hoverSpringDamping = NotchSettings.Defaults.hoverSpringDamping
@State private var hoverTask: Task<Void, Never>?
private var hoverAnimation: Animation {
.interactiveSpring(response: hoverSpringResponse, dampingFraction: hoverSpringDamping)
}
private var currentShape: NotchShape {
vm.notchState == .open
? (cornerRadiusScaling ? .opened : NotchShape(topCornerRadius: 0, bottomCornerRadius: 14))
: .closed
}
// MARK: - Body
var body: some View {
notchBody
.frame(
width: vm.notchSize.width,
height: vm.notchState == .open ? vm.notchSize.height : vm.closedNotchSize.height,
alignment: .top
)
.background(.black)
.clipShape(currentShape)
.overlay(alignment: .top) {
Rectangle().fill(.black).frame(height: 1)
}
.shadow(
color: enableShadow ? Color.black.opacity(shadowOpacity) : .clear,
radius: enableShadow ? shadowRadius : 0
)
// Single opacity control everything inside is solid black,
// so this one modifier makes it all uniformly transparent.
.opacity(notchOpacity)
.blur(radius: blurRadius)
.animation(vm.notchState == .open ? vm.openAnimation : vm.closeAnimation, value: vm.notchState)
.animation(vm.notchState == .open ? vm.openAnimation : vm.closeAnimation, value: vm.notchSize.width)
.animation(vm.notchState == .open ? vm.openAnimation : vm.closeAnimation, value: vm.notchSize.height)
.onHover { handleHover($0) }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.edgesIgnoringSafeArea(.all)
}
// MARK: - Content
@ViewBuilder
private var notchBody: some View {
if vm.notchState == .open {
openContent
.transition(.opacity)
} else {
closedContent
}
}
private var closedContent: some View {
HStack {
Spacer()
Text(abbreviate(terminalManager.activeTitle))
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.white.opacity(0.7))
.lineLimit(1)
Spacer()
}
.padding(.horizontal, 8)
.background(.black)
}
/// Open layout: VStack with toolbar row on top, terminal in the middle,
/// tab bar at the bottom. Every section has a black background.
private var openContent: some View {
VStack(spacing: 0) {
// Toolbar row right-aligned, solid black
HStack {
Spacer()
toolbarButton(icon: "arrow.up.forward.square", help: "Detach tab") {
if let session = terminalManager.detachActiveTab() {
PopoutWindowController.shared.popout(session: session)
}
}
toolbarButton(icon: "gearshape.fill", help: "Settings") {
SettingsWindowController.shared.showSettings()
}
}
.padding(.top, 6)
.padding(.trailing, 10)
.padding(.bottom, 2)
.background(.black)
// Terminal fills remaining space
if let session = terminalManager.activeTab {
SwiftTermView(session: session)
.id(session.id)
.padding(.leading, 10)
.padding(.trailing, 10)
}
// Tab bar
TabBar(terminalManager: terminalManager)
}
.background(.black)
}
private func toolbarButton(icon: String, help: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
Image(systemName: icon)
.font(.system(size: 11))
.foregroundStyle(.white.opacity(0.45))
.padding(4)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.help(help)
}
// MARK: - Hover
private func handleHover(_ hovering: Bool) {
if hovering {
withAnimation(hoverAnimation) { vm.isHovering = true }
guard openNotchOnHover, vm.notchState == .closed else { return }
hoverTask?.cancel()
hoverTask = Task { @MainActor in
try? await Task.sleep(nanoseconds: UInt64(minimumHoverDuration * 1_000_000_000))
guard !Task.isCancelled, vm.isHovering else { return }
vm.requestOpen?()
}
} else {
hoverTask?.cancel()
withAnimation(hoverAnimation) { vm.isHovering = false }
}
}
private func abbreviate(_ title: String) -> String {
title.count <= 30 ? title : String(title.prefix(28)) + ""
}
}

View File

@@ -0,0 +1,36 @@
import SwiftUI
/// Main entry point for the Downterm application.
/// Provides a MenuBarExtra for quick access to settings and app controls.
/// The notch windows and terminal sessions are managed by AppDelegate + ScreenManager.
@main
struct DowntermApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@AppStorage(NotchSettings.Keys.showMenuBarIcon)
private var showMenuBarIcon = NotchSettings.Defaults.showMenuBarIcon
var body: some Scene {
MenuBarExtra("Downterm", systemImage: "terminal", isInserted: $showMenuBarIcon) {
Button("Toggle Notch") {
ScreenManager.shared.toggleNotchOnActiveScreen()
}
.keyboardShortcut(.return, modifiers: .command)
Divider()
Button("Settings...") {
SettingsWindowController.shared.showSettings()
}
.keyboardShortcut(",", modifiers: .command)
Divider()
Button("Quit Downterm") {
NSApplication.shared.terminate(nil)
}
.keyboardShortcut("Q", modifiers: .command)
}
}
}

View File

@@ -0,0 +1,85 @@
import AppKit
extension NSScreen {
// MARK: - Stable display identifier
/// Returns a stable UUID string for this screen by querying CoreGraphics.
/// Falls back to the localized name if the CG UUID is unavailable.
var displayUUID: String {
guard let screenNumber = deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID else {
return localizedName
}
guard let uuid = CGDisplayCreateUUIDFromDisplayID(screenNumber) else {
return localizedName
}
return CFUUIDCreateString(nil, uuid.takeUnretainedValue()) as String
}
// MARK: - Notch detection
/// `true` when this screen has a physical camera notch (safe area inset at top > 0).
var hasNotch: Bool {
safeAreaInsets.top > 0
}
// MARK: - Closed notch sizing
/// Computes the closed-state notch size for this screen,
/// respecting the user's height mode and custom height preferences.
func closedNotchSize() -> CGSize {
let height = closedNotchHeight()
let width = closedNotchWidth()
return CGSize(width: width, height: height)
}
/// Height of the closed notch bar, determined by the user's chosen mode.
private func closedNotchHeight() -> CGFloat {
let defaults = UserDefaults.standard
if hasNotch {
let mode = NotchHeightMode(rawValue: defaults.integer(forKey: NotchSettings.Keys.notchHeightMode))
?? .matchRealNotchSize
switch mode {
case .matchRealNotchSize:
return safeAreaInsets.top
case .matchMenuBar:
return menuBarHeight()
case .custom:
return defaults.double(forKey: NotchSettings.Keys.notchHeight)
}
} else {
let mode = NonNotchHeightMode(rawValue: defaults.integer(forKey: NotchSettings.Keys.nonNotchHeightMode))
?? .matchMenuBar
switch mode {
case .matchMenuBar:
return menuBarHeight()
case .custom:
return defaults.double(forKey: NotchSettings.Keys.nonNotchHeight)
}
}
}
/// Width of the closed notch.
/// On notch screens, spans from one auxiliary top area to the other.
/// On non-notch screens, uses a reasonable fixed width.
private func closedNotchWidth() -> CGFloat {
if hasNotch {
if let topLeft = auxiliaryTopLeftArea,
let topRight = auxiliaryTopRightArea {
// The notch occupies the space between the two menu bar segments
return frame.width - topLeft.width - topRight.width + 4
}
// Fallback for older API approximate from safe area
return 220
} else {
// Non-notch screens: a compact simulated notch
return 220
}
}
/// The effective menu bar height for this screen.
private func menuBarHeight() -> CGFloat {
return frame.maxY - visibleFrame.maxY
}
}

View File

@@ -0,0 +1,241 @@
import AppKit
import Carbon.HIToolbox
/// Manages global and local hotkeys.
///
/// The toggle hotkey uses Carbon's `RegisterEventHotKey` which works
/// system-wide without Accessibility permission. Tab-level hotkeys
/// use a local `NSEvent` monitor (only fires when our app is active).
class HotkeyManager {
static let shared = HotkeyManager()
// MARK: - Callbacks
var onToggle: (() -> Void)?
var onNewTab: (() -> Void)?
var onCloseTab: (() -> Void)?
var onNextTab: (() -> Void)?
var onPreviousTab: (() -> Void)?
var onDetachTab: (() -> Void)?
var onSwitchToTab: ((Int) -> Void)?
/// Tab-level hotkeys only fire when the notch is open.
var isNotchOpen: Bool = false
private var hotKeyRef: EventHotKeyRef?
private var eventHandlerRef: EventHandlerRef?
private var localMonitor: Any?
private var defaultsObserver: NSObjectProtocol?
private init() {}
// MARK: - Resolved bindings (live from UserDefaults)
private var toggleBinding: HotkeyBinding {
binding(for: NotchSettings.Keys.hotkeyToggle) ?? .cmdReturn
}
private var newTabBinding: HotkeyBinding {
binding(for: NotchSettings.Keys.hotkeyNewTab) ?? .cmdT
}
private var closeTabBinding: HotkeyBinding {
binding(for: NotchSettings.Keys.hotkeyCloseTab) ?? .cmdW
}
private var nextTabBinding: HotkeyBinding {
binding(for: NotchSettings.Keys.hotkeyNextTab) ?? .cmdShiftRB
}
private var prevTabBinding: HotkeyBinding {
binding(for: NotchSettings.Keys.hotkeyPreviousTab) ?? .cmdShiftLB
}
private var detachBinding: HotkeyBinding {
binding(for: NotchSettings.Keys.hotkeyDetachTab) ?? .cmdD
}
private func binding(for key: String) -> HotkeyBinding? {
guard let json = UserDefaults.standard.string(forKey: key) else { return nil }
return HotkeyBinding.fromJSON(json)
}
// MARK: - Start / Stop
func start() {
installCarbonHandler()
registerToggleHotkey()
installLocalMonitor()
observeToggleHotkeyChanges()
}
func stop() {
unregisterToggleHotkey()
removeCarbonHandler()
removeLocalMonitor()
if let obs = defaultsObserver {
NotificationCenter.default.removeObserver(obs)
defaultsObserver = nil
}
}
// MARK: - Carbon global hotkey (toggle)
/// Installs a Carbon event handler that receives `kEventHotKeyPressed`
/// events when a registered hotkey fires works system-wide.
private func installCarbonHandler() {
var eventType = EventTypeSpec(
eventClass: OSType(kEventClassKeyboard),
eventKind: UInt32(kEventHotKeyPressed)
)
// Closure must not capture self uses the singleton accessor instead.
let status = InstallEventHandler(
GetApplicationEventTarget(),
{ (_: EventHandlerCallRef?, theEvent: EventRef?, _: UnsafeMutableRawPointer?) -> OSStatus in
guard let theEvent else { return OSStatus(eventNotHandledErr) }
var hotKeyID = EventHotKeyID()
let err = GetEventParameter(
theEvent,
EventParamName(kEventParamDirectObject),
EventParamType(typeEventHotKeyID),
nil,
MemoryLayout<EventHotKeyID>.size,
nil,
&hotKeyID
)
guard err == noErr else { return err }
if hotKeyID.id == 1 {
DispatchQueue.main.async {
HotkeyManager.shared.onToggle?()
}
}
return noErr
},
1,
&eventType,
nil,
&eventHandlerRef
)
if status != noErr {
print("[HotkeyManager] Failed to install Carbon event handler: \(status)")
}
}
private func registerToggleHotkey() {
unregisterToggleHotkey()
let binding = toggleBinding
let carbonMods = carbonModifiers(from: binding.modifiers)
var hotKeyID = EventHotKeyID(
signature: OSType(0x444E5452), // "DNTR"
id: 1
)
let status = RegisterEventHotKey(
UInt32(binding.keyCode),
carbonMods,
hotKeyID,
GetApplicationEventTarget(),
0,
&hotKeyRef
)
if status != noErr {
print("[HotkeyManager] Failed to register toggle hotkey: \(status)")
}
}
private func unregisterToggleHotkey() {
if let ref = hotKeyRef {
UnregisterEventHotKey(ref)
hotKeyRef = nil
}
}
private func removeCarbonHandler() {
if let ref = eventHandlerRef {
RemoveEventHandler(ref)
eventHandlerRef = nil
}
}
/// Re-register the toggle hotkey whenever the user changes it in settings.
private func observeToggleHotkeyChanges() {
defaultsObserver = NotificationCenter.default.addObserver(
forName: UserDefaults.didChangeNotification,
object: nil,
queue: .main
) { [weak self] _ in
self?.registerToggleHotkey()
}
}
// MARK: - Local monitor (tab-level hotkeys, only when our app is active)
private func installLocalMonitor() {
localMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
guard let self else { return event }
return self.handleLocalKeyEvent(event) ? nil : event
}
}
private func removeLocalMonitor() {
if let m = localMonitor {
NSEvent.removeMonitor(m)
localMonitor = nil
}
}
/// Handles tab-level hotkeys. Returns true if the event was consumed.
private func handleLocalKeyEvent(_ event: NSEvent) -> Bool {
// Tab hotkeys only when the notch is open and focused
guard isNotchOpen else { return false }
if newTabBinding.matches(event) {
onNewTab?()
return true
}
if closeTabBinding.matches(event) {
onCloseTab?()
return true
}
if nextTabBinding.matches(event) {
onNextTab?()
return true
}
if prevTabBinding.matches(event) {
onPreviousTab?()
return true
}
if detachBinding.matches(event) {
onDetachTab?()
return true
}
// Cmd+1 through Cmd+9
if event.modifierFlags.contains(.command) {
let digitKeyCodes: [UInt16: Int] = [
18: 0, 19: 1, 20: 2, 21: 3, 23: 4,
22: 5, 26: 6, 28: 7, 25: 8
]
if let tabIndex = digitKeyCodes[event.keyCode] {
onSwitchToTab?(tabIndex)
return true
}
}
return false
}
// MARK: - Carbon modifier conversion
private func carbonModifiers(from nsModifiers: UInt) -> UInt32 {
var carbon: UInt32 = 0
let flags = NSEvent.ModifierFlags(rawValue: nsModifiers)
if flags.contains(.command) { carbon |= UInt32(cmdKey) }
if flags.contains(.shift) { carbon |= UInt32(shiftKey) }
if flags.contains(.option) { carbon |= UInt32(optionKey) }
if flags.contains(.control) { carbon |= UInt32(controlKey) }
return carbon
}
}

View File

@@ -0,0 +1,73 @@
import AppKit
import SwiftUI
import SwiftTerm
/// Manages standalone pop-out terminal windows for detached tabs.
/// Each detached tab gets its own resizable window with the terminal view.
@MainActor
class PopoutWindowController: NSObject, NSWindowDelegate {
static let shared = PopoutWindowController()
/// Tracks open pop-out windows so they aren't released prematurely.
private var windows: [UUID: NSWindow] = [:]
private var sessions: [UUID: TerminalSession] = [:]
private override init() {
super.init()
}
/// Creates a new standalone window for the given terminal session.
func popout(session: TerminalSession) {
let windowID = session.id
let win = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 720, height: 480),
styleMask: [.titled, .closable, .resizable, .miniaturizable],
backing: .buffered,
defer: false
)
win.title = session.title
win.appearance = NSAppearance(named: .darkAqua)
win.backgroundColor = .black
win.delegate = self
win.isReleasedWhenClosed = false
// Embed the terminal view directly
let tv = session.terminalView
tv.removeFromSuperview()
tv.frame = NSRect(origin: .zero, size: win.contentView!.bounds.size)
tv.autoresizingMask = [.width, .height]
win.contentView?.addSubview(tv)
win.center()
win.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
windows[windowID] = win
sessions[windowID] = session
// Update window title when the terminal title changes
session.$title
.receive(on: RunLoop.main)
.sink { [weak win] title in win?.title = title }
.store(in: &popoutCancellables)
}
private var popoutCancellables = Set<AnyCancellable>()
// MARK: - NSWindowDelegate
func windowWillClose(_ notification: Notification) {
guard let closingWindow = notification.object as? NSWindow else { return }
// Find which session this window belongs to and clean up
if let entry = windows.first(where: { $0.value === closingWindow }) {
sessions[entry.key]?.terminate()
sessions.removeValue(forKey: entry.key)
windows.removeValue(forKey: entry.key)
}
}
}
import Combine

View File

@@ -0,0 +1,250 @@
import AppKit
import SwiftUI
import Combine
/// Manages one NotchWindow per connected display.
/// Routes all open/close through centralized methods that handle
/// window activation, key status, and first responder assignment
/// so the terminal can receive keyboard input.
@MainActor
class ScreenManager: ObservableObject {
static let shared = ScreenManager()
private(set) var windows: [String: NotchWindow] = [:]
private(set) var viewModels: [String: NotchViewModel] = [:]
@AppStorage(NotchSettings.Keys.showOnAllDisplays)
private var showOnAllDisplays = NotchSettings.Defaults.showOnAllDisplays
private var cancellables = Set<AnyCancellable>()
private init() {}
// MARK: - Lifecycle
func start() {
observeScreenChanges()
rebuildWindows()
setupHotkeys()
}
func stop() {
cleanupAllWindows()
cancellables.removeAll()
HotkeyManager.shared.stop()
}
// MARK: - Hotkey wiring
private func setupHotkeys() {
let hk = HotkeyManager.shared
let tm = TerminalManager.shared
// Callbacks are invoked on the main thread by HotkeyManager.
// MainActor.assumeIsolated lets us safely call @MainActor methods.
hk.onToggle = { [weak self] in
MainActor.assumeIsolated { self?.toggleNotchOnActiveScreen() }
}
hk.onNewTab = { MainActor.assumeIsolated { tm.newTab() } }
hk.onCloseTab = { MainActor.assumeIsolated { tm.closeActiveTab() } }
hk.onNextTab = { MainActor.assumeIsolated { tm.nextTab() } }
hk.onPreviousTab = { MainActor.assumeIsolated { tm.previousTab() } }
hk.onDetachTab = { [weak self] in
MainActor.assumeIsolated { self?.detachActiveTab() }
}
hk.onSwitchToTab = { index in
MainActor.assumeIsolated { tm.switchToTab(at: index) }
}
hk.start()
}
// MARK: - Toggle
func toggleNotchOnActiveScreen() {
let mouseLocation = NSEvent.mouseLocation
let targetScreen = NSScreen.screens.first { NSMouseInRect(mouseLocation, $0.frame, false) }
?? NSScreen.main
guard let screen = targetScreen else { return }
let uuid = screen.displayUUID
// Close any other open notch first
for (otherUUID, otherVM) in viewModels where otherUUID != uuid {
if otherVM.notchState == .open {
closeNotch(screenUUID: otherUUID)
}
}
if let vm = viewModels[uuid] {
if vm.notchState == .open {
closeNotch(screenUUID: uuid)
} else {
openNotch(screenUUID: uuid)
}
}
}
// MARK: - Open / Close
func openNotch(screenUUID: String) {
guard let vm = viewModels[screenUUID],
let window = windows[screenUUID] else { return }
withAnimation(vm.openAnimation) {
vm.open()
}
window.isNotchOpen = true
HotkeyManager.shared.isNotchOpen = true
// Activate the app so the window can become key.
NSApp.activate(ignoringOtherApps: true)
window.makeKeyAndOrderFront(nil)
// After layout settles, push keyboard focus to the terminal view
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
if let tv = TerminalManager.shared.activeTab?.terminalView {
window.makeFirstResponder(tv)
}
}
}
func closeNotch(screenUUID: String) {
guard let vm = viewModels[screenUUID],
let window = windows[screenUUID] else { return }
withAnimation(vm.closeAnimation) {
vm.close()
}
window.isNotchOpen = false
HotkeyManager.shared.isNotchOpen = false
}
private func detachActiveTab() {
if let session = TerminalManager.shared.detachActiveTab() {
PopoutWindowController.shared.popout(session: session)
}
}
// MARK: - Window creation
func rebuildWindows() {
cleanupAllWindows()
let screens: [NSScreen]
if showOnAllDisplays {
screens = NSScreen.screens
} else {
screens = [NSScreen.main].compactMap { $0 }
}
for screen in screens {
createWindow(for: screen)
}
}
private func createWindow(for screen: NSScreen) {
let uuid = screen.displayUUID
let vm = NotchViewModel(screenUUID: uuid)
let shadowPadding: CGFloat = 20
let openSize = vm.openNotchSize
let windowWidth = max(openSize.width + 40, screen.frame.width * 0.5)
let windowHeight = openSize.height + shadowPadding
let windowRect = NSRect(
x: screen.frame.origin.x + (screen.frame.width - windowWidth) / 2,
y: screen.frame.origin.y + screen.frame.height - windowHeight,
width: windowWidth,
height: windowHeight
)
let window = NotchWindow(
contentRect: windowRect,
styleMask: [.borderless, .nonactivatingPanel, .utilityWindow],
backing: .buffered,
defer: false
)
// Close the notch when the window loses focus
window.onResignKey = { [weak self] in
self?.closeNotch(screenUUID: uuid)
}
// Wire the ViewModel callbacks so ContentView routes through us
vm.requestOpen = { [weak self] in
self?.openNotch(screenUUID: uuid)
}
vm.requestClose = { [weak self] in
self?.closeNotch(screenUUID: uuid)
}
let hostingView = NSHostingView(
rootView: ContentView(vm: vm, terminalManager: TerminalManager.shared)
.preferredColorScheme(.dark)
)
hostingView.frame = NSRect(origin: .zero, size: windowRect.size)
window.contentView = hostingView
window.setFrame(windowRect, display: true)
window.orderFrontRegardless()
windows[uuid] = window
viewModels[uuid] = vm
}
// MARK: - Repositioning
func repositionWindows() {
for (uuid, window) in windows {
guard let screen = NSScreen.screens.first(where: { $0.displayUUID == uuid }) else { continue }
guard let vm = viewModels[uuid] else { continue }
vm.refreshClosedSize()
let shadowPadding: CGFloat = 20
let openSize = vm.openNotchSize
let windowWidth = max(openSize.width + 40, screen.frame.width * 0.5)
let windowHeight = openSize.height + shadowPadding
let newFrame = NSRect(
x: screen.frame.origin.x + (screen.frame.width - windowWidth) / 2,
y: screen.frame.origin.y + screen.frame.height - windowHeight,
width: windowWidth,
height: windowHeight
)
window.setFrame(newFrame, display: true)
}
}
// MARK: - Cleanup
private func cleanupAllWindows() {
for (_, window) in windows {
window.orderOut(nil)
window.close()
}
windows.removeAll()
viewModels.removeAll()
}
// MARK: - Screen observation
private func observeScreenChanges() {
NotificationCenter.default.publisher(for: NSApplication.didChangeScreenParametersNotification)
.debounce(for: .milliseconds(500), scheduler: RunLoop.main)
.sink { [weak self] _ in self?.handleScreenConfigurationChange() }
.store(in: &cancellables)
}
private func handleScreenConfigurationChange() {
let currentUUIDs = Set(NSScreen.screens.map { $0.displayUUID })
let knownUUIDs = Set(windows.keys)
if currentUUIDs != knownUUIDs {
rebuildWindows()
} else {
repositionWindows()
}
}
}

View File

@@ -0,0 +1,58 @@
import AppKit
import SwiftUI
/// Singleton controller that manages the settings window.
/// When the settings panel opens, the app becomes a regular app
/// (visible in Dock / Cmd-Tab). When it closes, the app reverts
/// to an accessory (menu-bar-only) app.
class SettingsWindowController: NSObject, NSWindowDelegate {
static let shared = SettingsWindowController()
private var window: NSWindow?
private override init() {
super.init()
}
// MARK: - Show / Hide
func showSettings() {
if let existing = window {
existing.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
return
}
let settingsView = SettingsView()
let hostingView = NSHostingView(rootView: settingsView)
let win = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 700, height: 500),
styleMask: [.titled, .closable, .resizable],
backing: .buffered,
defer: false
)
win.title = "Downterm Settings"
win.contentView = hostingView
win.center()
win.delegate = self
win.isReleasedWhenClosed = false
// Appear in Dock while settings are open
NSApp.setActivationPolicy(.regular)
win.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
window = win
}
// MARK: - NSWindowDelegate
func windowWillClose(_ notification: Notification) {
// Revert to accessory (menu-bar-only) mode
NSApp.setActivationPolicy(.accessory)
window = nil
}
}

View File

@@ -0,0 +1,92 @@
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 {
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 cmdD = HotkeyBinding(modifiers: NSEvent.ModifierFlags.command.rawValue, keyCode: 2)
}

View File

@@ -0,0 +1,170 @@
import Foundation
/// 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"
// 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"
// Behavior
static let enableGestures = "enableGestures"
static let gestureSensitivity = "gestureSensitivity"
// Terminal
static let terminalFontSize = "terminalFontSize"
static let terminalShell = "terminalShell"
// 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 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 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 enableGestures: Bool = true
static let gestureSensitivity: Double = 0.5
static let terminalFontSize: Double = 13
static let terminalShell: String = ""
// 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 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.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.enableGestures: Defaults.enableGestures,
Keys.gestureSensitivity: Defaults.gestureSensitivity,
Keys.terminalFontSize: Defaults.terminalFontSize,
Keys.terminalShell: Defaults.terminalShell,
Keys.hotkeyToggle: Defaults.hotkeyToggle,
Keys.hotkeyNewTab: Defaults.hotkeyNewTab,
Keys.hotkeyCloseTab: Defaults.hotkeyCloseTab,
Keys.hotkeyNextTab: Defaults.hotkeyNextTab,
Keys.hotkeyPreviousTab: Defaults.hotkeyPreviousTab,
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"
}
}
}

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,66 @@
import SwiftUI
import Combine
/// Per-screen observable state that drives the notch UI.
@MainActor
class NotchViewModel: ObservableObject {
let screenUUID: String
@Published var notchState: NotchState = .closed
@Published var notchSize: CGSize
@Published var closedNotchSize: CGSize
@Published var isHovering: 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)?
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
var openAnimation: Animation {
.spring(response: openSpringResponse, dampingFraction: openSpringDamping)
}
var closeAnimation: Animation {
.spring(response: closeSpringResponse, dampingFraction: closeSpringDamping)
}
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() {
notchSize = CGSize(width: openWidth, height: openHeight)
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 {
CGSize(width: openWidth, height: openHeight)
}
}

View File

@@ -0,0 +1,107 @@
import SwiftUI
import Combine
/// Manages multiple terminal tabs. Singleton shared across all screens
/// whichever notch is currently open displays these tabs.
@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
private var cancellables = Set<AnyCancellable>()
private init() {
newTab()
}
// MARK: - Active tab
var activeTab: TerminalSession? {
guard tabs.indices.contains(activeTabIndex) else { return nil }
return tabs[activeTabIndex]
}
/// Short title for the closed notch bar the active tab's process name.
var activeTitle: String {
activeTab?.title ?? "shell"
}
// MARK: - Tab operations
func newTab() {
let session = TerminalSession(fontSize: CGFloat(fontSize))
// 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
}
}
func closeActiveTab() {
closeTab(at: activeTabIndex)
}
func switchToTab(at index: Int) {
guard tabs.indices.contains(index) 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
}
/// 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
}
func detachActiveTab() -> TerminalSession? {
detachTab(at: activeTabIndex)
}
/// Updates font size on all existing terminal sessions.
func updateAllFontSizes(_ size: CGFloat) {
for tab in tabs {
tab.updateFontSize(size)
}
}
}

View File

@@ -0,0 +1,122 @@
import AppKit
import SwiftTerm
import Combine
/// Wraps a single SwiftTerm TerminalView + LocalProcess pair.
@MainActor
class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, TerminalViewDelegate {
let id = UUID()
let terminalView: TerminalView
private var process: LocalProcess?
@Published var title: String = "shell"
@Published var isRunning: Bool = true
@Published var currentDirectory: String?
init(fontSize: CGFloat) {
terminalView = TerminalView(frame: NSRect(x: 0, y: 0, width: 600, height: 300))
super.init()
terminalView.terminalDelegate = self
// Solid black matches every other element in the notch.
// The single `.opacity(notchOpacity)` on ContentView makes
// everything uniformly transparent.
terminalView.nativeBackgroundColor = .black
terminalView.nativeForegroundColor = .init(white: 0.9, alpha: 1.0)
let font = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular)
terminalView.font = font
startShell()
}
// MARK: - Shell management
private func startShell() {
let shellPath = resolveShell()
FileManager.default.changeCurrentDirectoryPath(NSHomeDirectory())
let proc = LocalProcess(delegate: self)
proc.startProcess(executable: shellPath)
process = proc
title = (shellPath as NSString).lastPathComponent
}
private func resolveShell() -> String {
let custom = UserDefaults.standard.string(forKey: NotchSettings.Keys.terminalShell) ?? ""
if !custom.isEmpty && FileManager.default.isExecutableFile(atPath: custom) {
return custom
}
return ProcessInfo.processInfo.environment["SHELL"] ?? "/bin/zsh"
}
func updateFontSize(_ size: CGFloat) {
terminalView.font = NSFont.monospacedSystemFont(ofSize: size, weight: .regular)
}
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,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict/>
</plist>

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Downterm</string>
<key>CFBundleExecutable</key>
<string>Downterm</string>
<key>CFBundleIdentifier</key>
<string>com.downterm.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Downterm</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.2.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>LSUIElement</key>
<true/>
<key>NSHumanReadableCopyright</key>
<string>Copyright © 2026 Downterm. All rights reserved.</string>
</dict>
</plist>

View File

@@ -0,0 +1,380 @@
import SwiftUI
/// Tabbed settings panel with General, Appearance, Animation, Terminal, Hotkeys, and About.
struct SettingsView: View {
@State private var selectedTab: SettingsTab = .general
var body: some View {
NavigationSplitView {
List(SettingsTab.allCases, selection: $selectedTab) { tab in
Label(tab.label, systemImage: tab.icon)
.tag(tab)
}
.listStyle(.sidebar)
.navigationSplitViewColumnWidth(min: 180, ideal: 200)
} detail: {
ScrollView {
detailView.padding()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.frame(minWidth: 600, minHeight: 400)
}
@ViewBuilder
private var detailView: some View {
switch selectedTab {
case .general: GeneralSettingsView()
case .appearance: AppearanceSettingsView()
case .animation: AnimationSettingsView()
case .terminal: TerminalSettingsView()
case .hotkeys: HotkeySettingsView()
case .about: AboutSettingsView()
}
}
}
// MARK: - Tabs
enum SettingsTab: String, CaseIterable, Identifiable {
case general, appearance, animation, terminal, hotkeys, about
var id: String { rawValue }
var label: String {
switch self {
case .general: return "General"
case .appearance: return "Appearance"
case .animation: return "Animation"
case .terminal: return "Terminal"
case .hotkeys: return "Hotkeys"
case .about: return "About"
}
}
var icon: String {
switch self {
case .general: return "gearshape"
case .appearance: return "paintbrush"
case .animation: return "bolt.fill"
case .terminal: return "terminal"
case .hotkeys: return "keyboard"
case .about: return "info.circle"
}
}
}
// MARK: - General
struct GeneralSettingsView: View {
@AppStorage(NotchSettings.Keys.showOnAllDisplays) private var showOnAllDisplays = NotchSettings.Defaults.showOnAllDisplays
@AppStorage(NotchSettings.Keys.openNotchOnHover) private var openNotchOnHover = NotchSettings.Defaults.openNotchOnHover
@AppStorage(NotchSettings.Keys.minimumHoverDuration) private var minimumHoverDuration = NotchSettings.Defaults.minimumHoverDuration
@AppStorage(NotchSettings.Keys.showMenuBarIcon) private var showMenuBarIcon = NotchSettings.Defaults.showMenuBarIcon
@AppStorage(NotchSettings.Keys.enableGestures) private var enableGestures = NotchSettings.Defaults.enableGestures
@AppStorage(NotchSettings.Keys.gestureSensitivity) private var gestureSensitivity = NotchSettings.Defaults.gestureSensitivity
@AppStorage(NotchSettings.Keys.notchHeightMode) private var notchHeightMode = NotchSettings.Defaults.notchHeightMode
@AppStorage(NotchSettings.Keys.notchHeight) private var notchHeight = NotchSettings.Defaults.notchHeight
@AppStorage(NotchSettings.Keys.nonNotchHeightMode) private var nonNotchHeightMode = NotchSettings.Defaults.nonNotchHeightMode
@AppStorage(NotchSettings.Keys.nonNotchHeight) private var nonNotchHeight = NotchSettings.Defaults.nonNotchHeight
@AppStorage(NotchSettings.Keys.openWidth) private var openWidth = NotchSettings.Defaults.openWidth
@AppStorage(NotchSettings.Keys.openHeight) private var openHeight = NotchSettings.Defaults.openHeight
var body: some View {
Form {
Section("Display") {
Toggle("Show on all displays", isOn: $showOnAllDisplays)
Toggle("Show menu bar icon", isOn: $showMenuBarIcon)
}
Section("Hover Behavior") {
Toggle("Open notch on hover", isOn: $openNotchOnHover)
if openNotchOnHover {
HStack {
Text("Hover delay")
Slider(value: $minimumHoverDuration, in: 0.0...2.0, step: 0.05)
Text(String(format: "%.2fs", minimumHoverDuration))
.monospacedDigit().frame(width: 50)
}
}
}
Section("Gestures") {
Toggle("Enable gestures", isOn: $enableGestures)
if enableGestures {
HStack {
Text("Sensitivity")
Slider(value: $gestureSensitivity, in: 0.1...1.0, step: 0.05)
Text(String(format: "%.2f", gestureSensitivity))
.monospacedDigit().frame(width: 50)
}
}
}
Section("Closed Notch Size") {
Picker("Notch screens", selection: $notchHeightMode) {
ForEach(NotchHeightMode.allCases) { Text($0.label).tag($0.rawValue) }
}
if notchHeightMode == NotchHeightMode.custom.rawValue {
HStack {
Text("Custom height")
Slider(value: $notchHeight, in: 16...64, step: 1)
Text("\(Int(notchHeight))pt").monospacedDigit().frame(width: 50)
}
}
Picker("Non-notch screens", selection: $nonNotchHeightMode) {
ForEach(NonNotchHeightMode.allCases) { Text($0.label).tag($0.rawValue) }
}
if nonNotchHeightMode == NonNotchHeightMode.custom.rawValue {
HStack {
Text("Custom height")
Slider(value: $nonNotchHeight, in: 16...64, step: 1)
Text("\(Int(nonNotchHeight))pt").monospacedDigit().frame(width: 50)
}
}
}
Section("Open Notch Size") {
HStack {
Text("Width")
Slider(value: $openWidth, in: 300...1200, step: 10)
Text("\(Int(openWidth))pt").monospacedDigit().frame(width: 60)
}
HStack {
Text("Height")
Slider(value: $openHeight, in: 100...600, step: 10)
Text("\(Int(openHeight))pt").monospacedDigit().frame(width: 60)
}
}
}
.formStyle(.grouped)
}
}
// MARK: - Appearance
struct AppearanceSettingsView: View {
@AppStorage(NotchSettings.Keys.enableShadow) private var enableShadow = NotchSettings.Defaults.enableShadow
@AppStorage(NotchSettings.Keys.shadowRadius) private var shadowRadius = NotchSettings.Defaults.shadowRadius
@AppStorage(NotchSettings.Keys.shadowOpacity) private var shadowOpacity = NotchSettings.Defaults.shadowOpacity
@AppStorage(NotchSettings.Keys.cornerRadiusScaling) private var cornerRadiusScaling = NotchSettings.Defaults.cornerRadiusScaling
@AppStorage(NotchSettings.Keys.notchOpacity) private var notchOpacity = NotchSettings.Defaults.notchOpacity
@AppStorage(NotchSettings.Keys.blurRadius) private var blurRadius = NotchSettings.Defaults.blurRadius
var body: some View {
Form {
Section("Shadow") {
Toggle("Enable shadow", isOn: $enableShadow)
if enableShadow {
HStack {
Text("Radius")
Slider(value: $shadowRadius, in: 0...30, step: 1)
Text(String(format: "%.0f", shadowRadius)).monospacedDigit().frame(width: 40)
}
HStack {
Text("Opacity")
Slider(value: $shadowOpacity, in: 0...1, step: 0.05)
Text(String(format: "%.2f", shadowOpacity)).monospacedDigit().frame(width: 50)
}
}
}
Section("Shape") {
Toggle("Scale corner radii when open", isOn: $cornerRadiusScaling)
}
Section("Opacity & Blur") {
HStack {
Text("Notch opacity")
Slider(value: $notchOpacity, in: 0...1, step: 0.05)
Text(String(format: "%.2f", notchOpacity)).monospacedDigit().frame(width: 50)
}
HStack {
Text("Blur radius")
Slider(value: $blurRadius, in: 0...20, step: 0.5)
Text(String(format: "%.1f", blurRadius)).monospacedDigit().frame(width: 50)
}
}
}
.formStyle(.grouped)
}
}
// MARK: - Animation
struct AnimationSettingsView: View {
@AppStorage(NotchSettings.Keys.openSpringResponse) private var openResponse = NotchSettings.Defaults.openSpringResponse
@AppStorage(NotchSettings.Keys.openSpringDamping) private var openDamping = NotchSettings.Defaults.openSpringDamping
@AppStorage(NotchSettings.Keys.closeSpringResponse) private var closeResponse = NotchSettings.Defaults.closeSpringResponse
@AppStorage(NotchSettings.Keys.closeSpringDamping) private var closeDamping = NotchSettings.Defaults.closeSpringDamping
@AppStorage(NotchSettings.Keys.hoverSpringResponse) private var hoverResponse = NotchSettings.Defaults.hoverSpringResponse
@AppStorage(NotchSettings.Keys.hoverSpringDamping) private var hoverDamping = NotchSettings.Defaults.hoverSpringDamping
var body: some View {
Form {
Section("Open Animation") {
springControls(response: $openResponse, damping: $openDamping)
}
Section("Close Animation") {
springControls(response: $closeResponse, damping: $closeDamping)
}
Section("Hover Animation") {
springControls(response: $hoverResponse, damping: $hoverDamping)
}
Section {
Button("Reset to Defaults") {
openResponse = NotchSettings.Defaults.openSpringResponse
openDamping = NotchSettings.Defaults.openSpringDamping
closeResponse = NotchSettings.Defaults.closeSpringResponse
closeDamping = NotchSettings.Defaults.closeSpringDamping
hoverResponse = NotchSettings.Defaults.hoverSpringResponse
hoverDamping = NotchSettings.Defaults.hoverSpringDamping
}
}
}
.formStyle(.grouped)
}
@ViewBuilder
private func springControls(response: Binding<Double>, damping: Binding<Double>) -> some View {
HStack {
Text("Response")
Slider(value: response, in: 0.1...1.5, step: 0.01)
Text(String(format: "%.2f", response.wrappedValue)).monospacedDigit().frame(width: 50)
}
HStack {
Text("Damping")
Slider(value: damping, in: 0.1...1.5, step: 0.01)
Text(String(format: "%.2f", damping.wrappedValue)).monospacedDigit().frame(width: 50)
}
}
}
// MARK: - Terminal
struct TerminalSettingsView: View {
@AppStorage(NotchSettings.Keys.terminalFontSize) private var fontSize = NotchSettings.Defaults.terminalFontSize
@AppStorage(NotchSettings.Keys.terminalShell) private var shellPath = NotchSettings.Defaults.terminalShell
var body: some View {
Form {
Section("Font") {
HStack {
Text("Font size")
Slider(value: $fontSize, in: 8...28, step: 1)
Text("\(Int(fontSize))pt").monospacedDigit().frame(width: 50)
}
}
Section("Shell") {
TextField("Shell path (empty = $SHELL)", text: $shellPath)
.textFieldStyle(.roundedBorder)
Text("Leave empty to use your default shell ($SHELL or /bin/zsh).")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
}
}
// MARK: - Hotkeys
struct HotkeySettingsView: View {
@State private var toggleBinding = loadBinding(NotchSettings.Keys.hotkeyToggle, fallback: .cmdReturn)
@State private var newTabBinding = loadBinding(NotchSettings.Keys.hotkeyNewTab, fallback: .cmdT)
@State private var closeTabBinding = loadBinding(NotchSettings.Keys.hotkeyCloseTab, fallback: .cmdW)
@State private var nextTabBinding = loadBinding(NotchSettings.Keys.hotkeyNextTab, fallback: .cmdShiftRB)
@State private var prevTabBinding = loadBinding(NotchSettings.Keys.hotkeyPreviousTab, fallback: .cmdShiftLB)
@State private var detachBinding = loadBinding(NotchSettings.Keys.hotkeyDetachTab, fallback: .cmdD)
var body: some View {
Form {
Section("Global") {
HotkeyRecorderView(label: "Toggle notch", binding: bindAndSave($toggleBinding, key: NotchSettings.Keys.hotkeyToggle))
}
Section("Terminal Tabs (active when notch is open)") {
HotkeyRecorderView(label: "New tab", binding: bindAndSave($newTabBinding, key: NotchSettings.Keys.hotkeyNewTab))
HotkeyRecorderView(label: "Close tab", binding: bindAndSave($closeTabBinding, key: NotchSettings.Keys.hotkeyCloseTab))
HotkeyRecorderView(label: "Next tab", binding: bindAndSave($nextTabBinding, key: NotchSettings.Keys.hotkeyNextTab))
HotkeyRecorderView(label: "Previous tab", binding: bindAndSave($prevTabBinding, key: NotchSettings.Keys.hotkeyPreviousTab))
HotkeyRecorderView(label: "Detach tab", binding: bindAndSave($detachBinding, key: NotchSettings.Keys.hotkeyDetachTab))
}
Section {
Text("⌘19 always switch to tab by number.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section {
Button("Reset to Defaults") {
toggleBinding = .cmdReturn
newTabBinding = .cmdT
closeTabBinding = .cmdW
nextTabBinding = .cmdShiftRB
prevTabBinding = .cmdShiftLB
detachBinding = .cmdD
save(.cmdReturn, key: NotchSettings.Keys.hotkeyToggle)
save(.cmdT, key: NotchSettings.Keys.hotkeyNewTab)
save(.cmdW, key: NotchSettings.Keys.hotkeyCloseTab)
save(.cmdShiftRB, key: NotchSettings.Keys.hotkeyNextTab)
save(.cmdShiftLB, key: NotchSettings.Keys.hotkeyPreviousTab)
save(.cmdD, key: NotchSettings.Keys.hotkeyDetachTab)
}
}
}
.formStyle(.grouped)
}
/// Creates a binding that saves to UserDefaults on every change.
private func bindAndSave(_ state: Binding<HotkeyBinding>, key: String) -> Binding<HotkeyBinding> {
Binding(
get: { state.wrappedValue },
set: { newValue in
state.wrappedValue = newValue
save(newValue, key: key)
}
)
}
private func save(_ binding: HotkeyBinding, key: String) {
UserDefaults.standard.set(binding.toJSON(), forKey: key)
}
private static func loadBinding(_ key: String, fallback: HotkeyBinding) -> HotkeyBinding {
guard let json = UserDefaults.standard.string(forKey: key),
let b = HotkeyBinding.fromJSON(json) else { return fallback }
return b
}
}
// MARK: - About
struct AboutSettingsView: View {
var body: some View {
VStack(spacing: 16) {
Image(systemName: "terminal")
.font(.system(size: 64))
.foregroundStyle(.secondary)
Text("Downterm")
.font(.largeTitle.bold())
Text("Version 0.3.0")
.foregroundStyle(.secondary)
Text("A drop-down terminal that lives in your notch.")
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
Spacer()
}
.frame(maxWidth: .infinity)
.padding(.top, 40)
}
}

48
Downterm/project.yml Normal file
View File

@@ -0,0 +1,48 @@
name: Downterm
options:
bundleIdPrefix: com.downterm
deploymentTarget:
macOS: "14.0"
xcodeVersion: "16.0"
generateEmptyDirectories: true
settings:
base:
SWIFT_VERSION: "5.10"
MACOSX_DEPLOYMENT_TARGET: "14.0"
ENABLE_HARDENED_RUNTIME: true
packages:
SwiftTerm:
url: https://github.com/migueldeicaza/SwiftTerm.git
from: "1.2.0"
targets:
Downterm:
type: application
platform: macOS
sources:
- path: Downterm
excludes:
- Resources/Info.plist
dependencies:
- package: SwiftTerm
info:
path: Downterm/Resources/Info.plist
properties:
CFBundleName: Downterm
CFBundleDisplayName: Downterm
CFBundleIdentifier: com.downterm.app
CFBundleVersion: "1"
CFBundleShortVersionString: "0.2.0"
CFBundlePackageType: APPL
CFBundleExecutable: Downterm
LSMinimumSystemVersion: "14.0"
LSUIElement: true
NSHumanReadableCopyright: "Copyright © 2026 Downterm. All rights reserved."
entitlements:
path: Downterm/Resources/Downterm.entitlements
settings:
base:
CODE_SIGN_ENTITLEMENTS: Downterm/Resources/Downterm.entitlements
INFOPLIST_FILE: Downterm/Resources/Info.plist
PRODUCT_BUNDLE_IDENTIFIER: com.downterm.app
PRODUCT_NAME: Downterm
COMBINE_HIDPI_IMAGES: true