Compare commits

4 Commits

93 changed files with 7154 additions and 2016 deletions

11
.github/assets/bch-qr-placeholder.svg vendored Normal file
View File

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="640" height="640" viewBox="0 0 640 640" role="img" aria-labelledby="title desc">
<title id="title">BCH QR placeholder</title>
<desc id="desc">Placeholder card reminding the maintainer to replace this with a real BCH QR code.</desc>
<rect width="640" height="640" rx="32" fill="#111111"/>
<rect x="64" y="64" width="512" height="512" rx="24" fill="#ffffff"/>
<path d="M156 156h128v128H156zM356 156h128v128H356zM156 356h128v128H156z" fill="#111111"/>
<path d="M192 192h56v56h-56zM392 192h56v56h-56zM192 392h56v56h-56z" fill="#ffffff"/>
<path d="M332 340h32v32h-32zM396 340h32v32h-32zM460 340h32v32h-32zM332 404h32v32h-32zM460 404h32v32h-32zM364 436h32v32h-32zM428 436h32v32h-32z" fill="#111111"/>
<text x="320" y="560" text-anchor="middle" fill="#111111" font-family="Menlo, Monaco, monospace" font-size="24">REPLACE WITH REAL BCH QR</text>
<text x="320" y="602" text-anchor="middle" fill="#ffffff" font-family="Menlo, Monaco, monospace" font-size="24">ADD YOUR bitcoincash: ADDRESS</text>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

BIN
.github/assets/downterm-open.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 912 KiB

BIN
.github/assets/downterm-settings.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

4
.gitignore vendored
View File

@@ -79,7 +79,5 @@ jspm_packages/
dist/ dist/
build/ build/
**/Release* # Mac... files
CommandNotch 20*
**/.DS_Store **/.DS_Store

90
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,90 @@
# Contributing
Thanks for contributing to Downterm.
## Before You Start
- Use macOS 14+.
- Install Xcode 16+.
- Install `xcodegen` with Homebrew.
- Expect the app target to still be named `CommandNotch` while the public project name is Downterm.
## Local Setup
```bash
cd Downterm
xcodegen generate --spec project.yml
open CommandNotch.xcodeproj
```
## Useful Commands
Generate the project:
```bash
cd Downterm
xcodegen generate --spec project.yml
```
Build:
```bash
cd Downterm
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \
xcodebuild build -project CommandNotch.xcodeproj -scheme CommandNotch -destination 'platform=macOS'
```
Run tests:
```bash
cd Downterm
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \
xcodebuild test -project CommandNotch.xcodeproj -scheme CommandNotch -destination 'platform=macOS'
```
## What Helps Most
- Bug fixes with a clear reproduction path.
- UI polish that keeps the app feeling intentional instead of generic.
- Accessibility improvements.
- Tests around workspace, screen, settings, and hotkey behavior.
- Docs and onboarding improvements.
## Code Guidelines
- Keep changes targeted.
- Preserve the existing SwiftUI + AppKit split.
- Prefer typed settings and explicit state ownership over hidden side effects.
- Add or update tests when you change behavior.
- Regenerate the Xcode project with XcodeGen if you add or remove files.
## Pull Requests
- Keep PRs small enough to review comfortably.
- Explain the user-facing impact.
- Note any follow-up work or tradeoffs.
- Include screenshots for visible UI changes when possible.
- Mention the exact build/test command you used.
## Issues
When filing a bug, include:
- macOS version
- what you expected
- what actually happened
- reproduction steps
- screenshots or recordings if they help
## Media Updates
README screenshots live in `.github/assets/`.
If you update the UI significantly, refresh:
- `.github/assets/downterm-open.png`
- `.github/assets/downterm-settings.png`
## License
By contributing, you agree that your contributions will be released under the MIT License in this repository.

View File

@@ -0,0 +1,787 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
088FE22B3308AAB25027E086 /* NotchShape.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F57837A7115DEEE11E14B40 /* NotchShape.swift */; };
0DE8E7F29FBAA7E2B613E221 /* AppearanceSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B3C8C11834931A9D53BC2B6 /* AppearanceSettingsView.swift */; };
0F133E8A88D2E313D90C32AD /* WindowFrameCalculatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D288132700770C4A625A15F6 /* WindowFrameCalculatorTests.swift */; };
12F68EDA880030DAB644FF5F /* LaunchAtLoginHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8210D7783A614FD7190F5DDD /* LaunchAtLoginHelper.swift */; };
154F363D434A26105C5999B5 /* WorkspaceRegistryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 591FCE91AF83A8A8E44E1625 /* WorkspaceRegistryTests.swift */; };
187F4B521BFC3BD29ADA79E3 /* HotkeyManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEB7FE2BDE5C25B7E599F340 /* HotkeyManager.swift */; };
1AB4A0F1BE668D3130EFBA93 /* TerminalTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFE9E5BADB0C427903A0D874 /* TerminalTheme.swift */; };
2089566A2BBAA65EA82119B3 /* NotchOrchestratorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF6D136A0B3FC79DDE12A826 /* NotchOrchestratorTests.swift */; };
2375B9DA559A0777FE558A8B /* WorkspaceStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FA62DFE9AD003F4C5B55F14 /* WorkspaceStoreTests.swift */; };
23E2DDCF36D0DAB2EA72C39C /* NotchState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B352301BC9CAD7C9D8B7AA9 /* NotchState.swift */; };
26AE379040149CBE05B314BB /* WorkspacesSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F70A31EFACF23DD9262A040E /* WorkspacesSettingsView.swift */; };
278010607B0D552DCC8996C5 /* CommandNotchUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 726B935606FD961FD7E8C2BE /* CommandNotchUITests.swift */; };
2DF22798D3A7514E2A9183FC /* WorkspaceSummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B2BCA543CE54DAB1DB80E43 /* WorkspaceSummary.swift */; };
34AF69BF7AF8DC78ADE3774A /* GeneralSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27E7EEEDFBD8D9CEB8FD86A5 /* GeneralSettingsView.swift */; };
3B69CB3CDEC2E5F2DCE600F9 /* NotchSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 297BA3E5B837FBDDEED9DE66 /* NotchSettings.swift */; };
4CFA0C79095ACE0A327A2469 /* WorkspaceRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E97758F68FACCFFACA895B7 /* WorkspaceRegistry.swift */; };
4D335F67B71F7DD977B6AEF9 /* AppSettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E6C98C406899F4B242075AF /* AppSettingsStore.swift */; };
4E0AD14B7427532271E485AA /* NotchWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFAC70814C72BAF76D90B9DF /* NotchWindow.swift */; };
507A67E770DEFAF5BC321FCF /* WindowCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9220E02B6D470DD05CA540C /* WindowCoordinator.swift */; };
5379DF2FACE924BDDB584377 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = C921E6435A64AA07A0FEA4D5 /* SwiftTerm */; };
5A3FDFCF30A1AAFE070290E9 /* SettingsWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 728B3125F7F7FDB7313D2DC6 /* SettingsWindowController.swift */; };
5F3534F66A5DBE4E081AFFA6 /* SwiftTermView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C55F29B779DA0E5C5FC8627 /* SwiftTermView.swift */; };
65C7DB7296C6C6A77598A1F4 /* TerminalSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E37A6DCD9C5DE1FE11C4C1CD /* TerminalSettingsView.swift */; };
6624A60CB121A22A03365071 /* WorkspaceSwitcherView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF0FFBC96F2446687D6474F4 /* WorkspaceSwitcherView.swift */; };
6A18F6635B509FF58669F505 /* HotkeySettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D03D042117E59DCA9D553844 /* HotkeySettingsView.swift */; };
6DD4DAE72E5C3858B230D94C /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7A8DCFA77626F9999B432FE /* AppDelegate.swift */; };
6F249EDFA2D654457DF385F1 /* TerminalManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567E85A2ED628460CEC760DB /* TerminalManager.swift */; };
771088361F981A9AAE976F3C /* SettingsBindings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 900F0476BE9E3600FBD371BB /* SettingsBindings.swift */; };
787477BB6D2F8AC723EEDA7D /* ScreenContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7181BB1F3926B457445105E5 /* ScreenContext.swift */; };
7A69B5AAC686174BCA54D0F0 /* TerminalSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49E1791BB45E1505500ACC67 /* TerminalSession.swift */; };
7A7D3455D50D041CF698E786 /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74463E4EAB78F56345360CD5 /* AppSettings.swift */; };
7AB14019B5CC6ED84B96FA47 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6ADD641A088A5005DD0EB4A6 /* Assets.xcassets */; };
7D5F3C3B5E173B6F06901812 /* AppSettingsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39CA6F2C2CA32D0AA58F6C43 /* AppSettingsStoreTests.swift */; };
86BB4A41E8EC44A5F45CE995 /* WorkspaceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CB1DFD6FCDF64B4DF24230A /* WorkspaceController.swift */; };
88113BA9B217DA579C36BEBE /* TabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72A1D3D12BAC593838B3125C /* TabBar.swift */; };
88D87155D79C493D8956AA3B /* AnimationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE99DBAB656EA80A117D2EE1 /* AnimationSettingsView.swift */; };
8A1B2A4CD61B0D9A4BAB075B /* ScreenRegistryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEC7F7D8D15A1BC4EE43DDDB /* ScreenRegistryTests.swift */; };
9F535B9516A4AE9FB536B1BD /* HotkeyBinding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BB1C403BC2157756F572ACF /* HotkeyBinding.swift */; };
A74E14B8AD19C53820853D8E /* NSScreen+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DDC7451F0E7ACFE0BEC5473 /* NSScreen+Extensions.swift */; };
AE8215538D4B026A00BAD241 /* WorkspaceStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3B957BD1F6120D2592613ED /* WorkspaceStore.swift */; };
B406183CC7B5DCE76989A066 /* AppSettingsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB28950392C0198E69F3564B /* AppSettingsController.swift */; };
B605A9311557251A85183383 /* AboutSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48198AFE5473B0F7AECAB3FB /* AboutSettingsView.swift */; };
C84FA3100884649FE92BF5DD /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A64A11F27E65B342B991629A /* ContentView.swift */; };
CFBBE994BA6BAD4658AAB9CB /* TerminalCommandArrowBehaviorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0D19729317029008D81F361 /* TerminalCommandArrowBehaviorTests.swift */; };
D088BC850F37E717844761C6 /* CommandNotchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5159CB9DBE2BAA0D2E201C39 /* CommandNotchApp.swift */; };
D2468B19D6F0A2C1DFDFE2B7 /* ScreenContextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A770A63582CF9834F4E7F058 /* ScreenContextTests.swift */; };
D4CEB4B895F75D91FA892A06 /* PopoutWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB593A2546BF2C0BE8E40387 /* PopoutWindowController.swift */; };
DAD3AB4A0DAADA32C02D959E /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3125FD3DC55420122CF85D80 /* SettingsView.swift */; };
DCFD5B03E64A46783F46726B /* TerminalCommandArrowBehavior.swift in Sources */ = {isa = PBXBuildFile; fileRef = F22AA47452CF798A977A6F47 /* TerminalCommandArrowBehavior.swift */; };
E63FD5862C0EC54E284F6A0F /* ScreenRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAF23753B5A0CAF04D7566A3 /* ScreenRegistry.swift */; };
E792411FA82E79E810F4B4C3 /* HotkeyRecorderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B81432CECBDB61D21EE4DC3 /* HotkeyRecorderView.swift */; };
EE72479BA5A25FF31BACCC50 /* NotchOrchestrator.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC7912AF01E1600B8619AF31 /* NotchOrchestrator.swift */; };
F8ED522FDC96B1F9AD8933F7 /* ScreenManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CB8AF76C0F728897A26D7EF /* ScreenManager.swift */; };
F8F8DE2F26608259D72635B7 /* AppSettingsControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F5FF5623898FA150C3B70D4 /* AppSettingsControllerTests.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
13E720E97D079D298D124BBE /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = E99C2EDB39F7B64C1540BCA8 /* Project object */;
proxyType = 1;
remoteGlobalIDString = D5585E5732CD067DF6EF0C69;
remoteInfo = CommandNotch;
};
9D6387038E18398CA764147F /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = E99C2EDB39F7B64C1540BCA8 /* Project object */;
proxyType = 1;
remoteGlobalIDString = D5585E5732CD067DF6EF0C69;
remoteInfo = CommandNotch;
};
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
0E97758F68FACCFFACA895B7 /* WorkspaceRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRegistry.swift; sourceTree = "<group>"; };
27E7EEEDFBD8D9CEB8FD86A5 /* GeneralSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralSettingsView.swift; sourceTree = "<group>"; };
297BA3E5B837FBDDEED9DE66 /* NotchSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchSettings.swift; sourceTree = "<group>"; };
2B81432CECBDB61D21EE4DC3 /* HotkeyRecorderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotkeyRecorderView.swift; sourceTree = "<group>"; };
3125FD3DC55420122CF85D80 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
35CBC14E11EBD8486457CE91 /* CommandNotchTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CommandNotchTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
39CA6F2C2CA32D0AA58F6C43 /* AppSettingsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettingsStoreTests.swift; sourceTree = "<group>"; };
3CB1DFD6FCDF64B4DF24230A /* WorkspaceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceController.swift; sourceTree = "<group>"; };
3F57837A7115DEEE11E14B40 /* NotchShape.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchShape.swift; sourceTree = "<group>"; };
3F5FF5623898FA150C3B70D4 /* AppSettingsControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettingsControllerTests.swift; sourceTree = "<group>"; };
48198AFE5473B0F7AECAB3FB /* AboutSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutSettingsView.swift; sourceTree = "<group>"; };
496267F03E261FEC9EBD5A9D /* CommandNotchUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CommandNotchUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
49E1791BB45E1505500ACC67 /* TerminalSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSession.swift; sourceTree = "<group>"; };
4B352301BC9CAD7C9D8B7AA9 /* NotchState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchState.swift; sourceTree = "<group>"; };
4FA62DFE9AD003F4C5B55F14 /* WorkspaceStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceStoreTests.swift; sourceTree = "<group>"; };
5159CB9DBE2BAA0D2E201C39 /* CommandNotchApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandNotchApp.swift; sourceTree = "<group>"; };
567E85A2ED628460CEC760DB /* TerminalManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalManager.swift; sourceTree = "<group>"; };
591FCE91AF83A8A8E44E1625 /* WorkspaceRegistryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRegistryTests.swift; sourceTree = "<group>"; };
5DDC7451F0E7ACFE0BEC5473 /* NSScreen+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSScreen+Extensions.swift"; sourceTree = "<group>"; };
6ADD641A088A5005DD0EB4A6 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
7181BB1F3926B457445105E5 /* ScreenContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenContext.swift; sourceTree = "<group>"; };
726B935606FD961FD7E8C2BE /* CommandNotchUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandNotchUITests.swift; sourceTree = "<group>"; };
728B3125F7F7FDB7313D2DC6 /* SettingsWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsWindowController.swift; sourceTree = "<group>"; };
72A1D3D12BAC593838B3125C /* TabBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBar.swift; sourceTree = "<group>"; };
74463E4EAB78F56345360CD5 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = "<group>"; };
7B2BCA543CE54DAB1DB80E43 /* WorkspaceSummary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceSummary.swift; sourceTree = "<group>"; };
8210D7783A614FD7190F5DDD /* LaunchAtLoginHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchAtLoginHelper.swift; sourceTree = "<group>"; };
8796768C84519077354A95C7 /* CommandNotch.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = CommandNotch.entitlements; sourceTree = "<group>"; };
8B3C8C11834931A9D53BC2B6 /* AppearanceSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceSettingsView.swift; sourceTree = "<group>"; };
8BB1C403BC2157756F572ACF /* HotkeyBinding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotkeyBinding.swift; sourceTree = "<group>"; };
8CB8AF76C0F728897A26D7EF /* ScreenManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenManager.swift; sourceTree = "<group>"; };
900F0476BE9E3600FBD371BB /* SettingsBindings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsBindings.swift; sourceTree = "<group>"; };
9C55F29B779DA0E5C5FC8627 /* SwiftTermView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftTermView.swift; sourceTree = "<group>"; };
9E6C98C406899F4B242075AF /* AppSettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettingsStore.swift; sourceTree = "<group>"; };
A64A11F27E65B342B991629A /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
A770A63582CF9834F4E7F058 /* ScreenContextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenContextTests.swift; sourceTree = "<group>"; };
AAF23753B5A0CAF04D7566A3 /* ScreenRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenRegistry.swift; sourceTree = "<group>"; };
B5AA68DC0DD3FE07B56EB6EC /* CommandNotch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CommandNotch.app; sourceTree = BUILT_PRODUCTS_DIR; };
C0D19729317029008D81F361 /* TerminalCommandArrowBehaviorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalCommandArrowBehaviorTests.swift; sourceTree = "<group>"; };
CB593A2546BF2C0BE8E40387 /* PopoutWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopoutWindowController.swift; sourceTree = "<group>"; };
CFE9E5BADB0C427903A0D874 /* TerminalTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalTheme.swift; sourceTree = "<group>"; };
D03D042117E59DCA9D553844 /* HotkeySettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotkeySettingsView.swift; sourceTree = "<group>"; };
D288132700770C4A625A15F6 /* WindowFrameCalculatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowFrameCalculatorTests.swift; sourceTree = "<group>"; };
DC7912AF01E1600B8619AF31 /* NotchOrchestrator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchOrchestrator.swift; sourceTree = "<group>"; };
DF0FFBC96F2446687D6474F4 /* WorkspaceSwitcherView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceSwitcherView.swift; sourceTree = "<group>"; };
E37A6DCD9C5DE1FE11C4C1CD /* TerminalSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSettingsView.swift; sourceTree = "<group>"; };
E7A8DCFA77626F9999B432FE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
EB28950392C0198E69F3564B /* AppSettingsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettingsController.swift; sourceTree = "<group>"; };
EEB7FE2BDE5C25B7E599F340 /* HotkeyManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotkeyManager.swift; sourceTree = "<group>"; };
EEC7F7D8D15A1BC4EE43DDDB /* ScreenRegistryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenRegistryTests.swift; sourceTree = "<group>"; };
EFAC70814C72BAF76D90B9DF /* NotchWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchWindow.swift; sourceTree = "<group>"; };
F22AA47452CF798A977A6F47 /* TerminalCommandArrowBehavior.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalCommandArrowBehavior.swift; sourceTree = "<group>"; };
F3B957BD1F6120D2592613ED /* WorkspaceStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceStore.swift; sourceTree = "<group>"; };
F70A31EFACF23DD9262A040E /* WorkspacesSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspacesSettingsView.swift; sourceTree = "<group>"; };
F9220E02B6D470DD05CA540C /* WindowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowCoordinator.swift; sourceTree = "<group>"; };
FE99DBAB656EA80A117D2EE1 /* AnimationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimationSettingsView.swift; sourceTree = "<group>"; };
FF6D136A0B3FC79DDE12A826 /* NotchOrchestratorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchOrchestratorTests.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
F3D057FF4247F13A1BBAE547 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
5379DF2FACE924BDDB584377 /* SwiftTerm in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
00BC30DD7FA5C3C26404733B /* Models */ = {
isa = PBXGroup;
children = (
74463E4EAB78F56345360CD5 /* AppSettings.swift */,
EB28950392C0198E69F3564B /* AppSettingsController.swift */,
9E6C98C406899F4B242075AF /* AppSettingsStore.swift */,
8BB1C403BC2157756F572ACF /* HotkeyBinding.swift */,
DC7912AF01E1600B8619AF31 /* NotchOrchestrator.swift */,
297BA3E5B837FBDDEED9DE66 /* NotchSettings.swift */,
4B352301BC9CAD7C9D8B7AA9 /* NotchState.swift */,
7181BB1F3926B457445105E5 /* ScreenContext.swift */,
AAF23753B5A0CAF04D7566A3 /* ScreenRegistry.swift */,
567E85A2ED628460CEC760DB /* TerminalManager.swift */,
49E1791BB45E1505500ACC67 /* TerminalSession.swift */,
CFE9E5BADB0C427903A0D874 /* TerminalTheme.swift */,
3CB1DFD6FCDF64B4DF24230A /* WorkspaceController.swift */,
0E97758F68FACCFFACA895B7 /* WorkspaceRegistry.swift */,
F3B957BD1F6120D2592613ED /* WorkspaceStore.swift */,
7B2BCA543CE54DAB1DB80E43 /* WorkspaceSummary.swift */,
);
path = Models;
sourceTree = "<group>";
};
618799FE544A4373B457DCDA /* Extensions */ = {
isa = PBXGroup;
children = (
5DDC7451F0E7ACFE0BEC5473 /* NSScreen+Extensions.swift */,
);
path = Extensions;
sourceTree = "<group>";
};
7043235A31A4023478DA1302 = {
isa = PBXGroup;
children = (
84740FA9CF6A18B35EC82623 /* CommandNotch */,
A2F9603AB9C86C4EA62FFA59 /* CommandNotchTests */,
E6C841E864A0CC68B9B05BAC /* CommandNotchUITests */,
B269158E04E8E603B61448F0 /* Products */,
);
sourceTree = "<group>";
};
84740FA9CF6A18B35EC82623 /* CommandNotch */ = {
isa = PBXGroup;
children = (
E7A8DCFA77626F9999B432FE /* AppDelegate.swift */,
5159CB9DBE2BAA0D2E201C39 /* CommandNotchApp.swift */,
A64A11F27E65B342B991629A /* ContentView.swift */,
D3C008AD1EFEF08E3417396F /* Components */,
618799FE544A4373B457DCDA /* Extensions */,
D87DBBE2E8779343A36F3490 /* Managers */,
00BC30DD7FA5C3C26404733B /* Models */,
C2F0251EB52960C9F437154D /* Resources */,
DEE792769214DF028395EA86 /* Views */,
);
path = CommandNotch;
sourceTree = "<group>";
};
A2F9603AB9C86C4EA62FFA59 /* CommandNotchTests */ = {
isa = PBXGroup;
children = (
3F5FF5623898FA150C3B70D4 /* AppSettingsControllerTests.swift */,
39CA6F2C2CA32D0AA58F6C43 /* AppSettingsStoreTests.swift */,
FF6D136A0B3FC79DDE12A826 /* NotchOrchestratorTests.swift */,
A770A63582CF9834F4E7F058 /* ScreenContextTests.swift */,
EEC7F7D8D15A1BC4EE43DDDB /* ScreenRegistryTests.swift */,
C0D19729317029008D81F361 /* TerminalCommandArrowBehaviorTests.swift */,
D288132700770C4A625A15F6 /* WindowFrameCalculatorTests.swift */,
591FCE91AF83A8A8E44E1625 /* WorkspaceRegistryTests.swift */,
4FA62DFE9AD003F4C5B55F14 /* WorkspaceStoreTests.swift */,
);
path = CommandNotchTests;
sourceTree = "<group>";
};
B269158E04E8E603B61448F0 /* Products */ = {
isa = PBXGroup;
children = (
B5AA68DC0DD3FE07B56EB6EC /* CommandNotch.app */,
35CBC14E11EBD8486457CE91 /* CommandNotchTests.xctest */,
496267F03E261FEC9EBD5A9D /* CommandNotchUITests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
C2F0251EB52960C9F437154D /* Resources */ = {
isa = PBXGroup;
children = (
6ADD641A088A5005DD0EB4A6 /* Assets.xcassets */,
8796768C84519077354A95C7 /* CommandNotch.entitlements */,
);
path = Resources;
sourceTree = "<group>";
};
D3C008AD1EFEF08E3417396F /* Components */ = {
isa = PBXGroup;
children = (
2B81432CECBDB61D21EE4DC3 /* HotkeyRecorderView.swift */,
3F57837A7115DEEE11E14B40 /* NotchShape.swift */,
EFAC70814C72BAF76D90B9DF /* NotchWindow.swift */,
9C55F29B779DA0E5C5FC8627 /* SwiftTermView.swift */,
72A1D3D12BAC593838B3125C /* TabBar.swift */,
F22AA47452CF798A977A6F47 /* TerminalCommandArrowBehavior.swift */,
);
path = Components;
sourceTree = "<group>";
};
D87DBBE2E8779343A36F3490 /* Managers */ = {
isa = PBXGroup;
children = (
EEB7FE2BDE5C25B7E599F340 /* HotkeyManager.swift */,
8210D7783A614FD7190F5DDD /* LaunchAtLoginHelper.swift */,
CB593A2546BF2C0BE8E40387 /* PopoutWindowController.swift */,
8CB8AF76C0F728897A26D7EF /* ScreenManager.swift */,
728B3125F7F7FDB7313D2DC6 /* SettingsWindowController.swift */,
F9220E02B6D470DD05CA540C /* WindowCoordinator.swift */,
);
path = Managers;
sourceTree = "<group>";
};
DEE792769214DF028395EA86 /* Views */ = {
isa = PBXGroup;
children = (
48198AFE5473B0F7AECAB3FB /* AboutSettingsView.swift */,
FE99DBAB656EA80A117D2EE1 /* AnimationSettingsView.swift */,
8B3C8C11834931A9D53BC2B6 /* AppearanceSettingsView.swift */,
27E7EEEDFBD8D9CEB8FD86A5 /* GeneralSettingsView.swift */,
D03D042117E59DCA9D553844 /* HotkeySettingsView.swift */,
900F0476BE9E3600FBD371BB /* SettingsBindings.swift */,
3125FD3DC55420122CF85D80 /* SettingsView.swift */,
E37A6DCD9C5DE1FE11C4C1CD /* TerminalSettingsView.swift */,
F70A31EFACF23DD9262A040E /* WorkspacesSettingsView.swift */,
DF0FFBC96F2446687D6474F4 /* WorkspaceSwitcherView.swift */,
);
path = Views;
sourceTree = "<group>";
};
E6C841E864A0CC68B9B05BAC /* CommandNotchUITests */ = {
isa = PBXGroup;
children = (
726B935606FD961FD7E8C2BE /* CommandNotchUITests.swift */,
);
path = CommandNotchUITests;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
036FDAECD12C0A679DA1F5D6 /* CommandNotchTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 983C618ACECB88BD023F005E /* Build configuration list for PBXNativeTarget "CommandNotchTests" */;
buildPhases = (
4108B7D3B592DEBB95C689C4 /* Sources */,
);
buildRules = (
);
dependencies = (
316871D68B87C00F5A8FEECC /* PBXTargetDependency */,
);
name = CommandNotchTests;
packageProductDependencies = (
);
productName = CommandNotchTests;
productReference = 35CBC14E11EBD8486457CE91 /* CommandNotchTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
1C8D00CBB29219BD347E9CC4 /* CommandNotchUITests */ = {
isa = PBXNativeTarget;
buildConfigurationList = CEAE976FCC3F5BD54E57E585 /* Build configuration list for PBXNativeTarget "CommandNotchUITests" */;
buildPhases = (
1215938A5211847A086CC444 /* Sources */,
);
buildRules = (
);
dependencies = (
F9E4A521E345300B08E257EA /* PBXTargetDependency */,
);
name = CommandNotchUITests;
packageProductDependencies = (
);
productName = CommandNotchUITests;
productReference = 496267F03E261FEC9EBD5A9D /* CommandNotchUITests.xctest */;
productType = "com.apple.product-type.bundle.ui-testing";
};
D5585E5732CD067DF6EF0C69 /* CommandNotch */ = {
isa = PBXNativeTarget;
buildConfigurationList = E599EC932C075AF0FD763A0E /* Build configuration list for PBXNativeTarget "CommandNotch" */;
buildPhases = (
D5B7874B63551D908A4B76C8 /* Sources */,
3BD8FCDCDA6E37ED22A35CA5 /* Resources */,
F3D057FF4247F13A1BBAE547 /* Frameworks */,
);
buildRules = (
);
dependencies = (
);
name = CommandNotch;
packageProductDependencies = (
C921E6435A64AA07A0FEA4D5 /* SwiftTerm */,
);
productName = CommandNotch;
productReference = B5AA68DC0DD3FE07B56EB6EC /* CommandNotch.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
E99C2EDB39F7B64C1540BCA8 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1600;
TargetAttributes = {
1C8D00CBB29219BD347E9CC4 = {
DevelopmentTeam = G698BP272N;
TestTargetID = D5585E5732CD067DF6EF0C69;
};
};
};
buildConfigurationList = C47A3896770C98F2A3E62B7A /* Build configuration list for PBXProject "CommandNotch" */;
compatibilityVersion = "Xcode 14.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
Base,
en,
);
mainGroup = 7043235A31A4023478DA1302;
minimizedProjectReferenceProxies = 1;
packageReferences = (
28377BE3F9997892D4929B6E /* XCRemoteSwiftPackageReference "SwiftTerm" */,
);
preferredProjectObjectVersion = 77;
projectDirPath = "";
projectRoot = "";
targets = (
D5585E5732CD067DF6EF0C69 /* CommandNotch */,
036FDAECD12C0A679DA1F5D6 /* CommandNotchTests */,
1C8D00CBB29219BD347E9CC4 /* CommandNotchUITests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
3BD8FCDCDA6E37ED22A35CA5 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
7AB14019B5CC6ED84B96FA47 /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
1215938A5211847A086CC444 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
278010607B0D552DCC8996C5 /* CommandNotchUITests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
4108B7D3B592DEBB95C689C4 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
F8F8DE2F26608259D72635B7 /* AppSettingsControllerTests.swift in Sources */,
7D5F3C3B5E173B6F06901812 /* AppSettingsStoreTests.swift in Sources */,
2089566A2BBAA65EA82119B3 /* NotchOrchestratorTests.swift in Sources */,
D2468B19D6F0A2C1DFDFE2B7 /* ScreenContextTests.swift in Sources */,
8A1B2A4CD61B0D9A4BAB075B /* ScreenRegistryTests.swift in Sources */,
CFBBE994BA6BAD4658AAB9CB /* TerminalCommandArrowBehaviorTests.swift in Sources */,
0F133E8A88D2E313D90C32AD /* WindowFrameCalculatorTests.swift in Sources */,
154F363D434A26105C5999B5 /* WorkspaceRegistryTests.swift in Sources */,
2375B9DA559A0777FE558A8B /* WorkspaceStoreTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
D5B7874B63551D908A4B76C8 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
B605A9311557251A85183383 /* AboutSettingsView.swift in Sources */,
88D87155D79C493D8956AA3B /* AnimationSettingsView.swift in Sources */,
6DD4DAE72E5C3858B230D94C /* AppDelegate.swift in Sources */,
7A7D3455D50D041CF698E786 /* AppSettings.swift in Sources */,
B406183CC7B5DCE76989A066 /* AppSettingsController.swift in Sources */,
4D335F67B71F7DD977B6AEF9 /* AppSettingsStore.swift in Sources */,
0DE8E7F29FBAA7E2B613E221 /* AppearanceSettingsView.swift in Sources */,
D088BC850F37E717844761C6 /* CommandNotchApp.swift in Sources */,
C84FA3100884649FE92BF5DD /* ContentView.swift in Sources */,
34AF69BF7AF8DC78ADE3774A /* GeneralSettingsView.swift in Sources */,
9F535B9516A4AE9FB536B1BD /* HotkeyBinding.swift in Sources */,
187F4B521BFC3BD29ADA79E3 /* HotkeyManager.swift in Sources */,
E792411FA82E79E810F4B4C3 /* HotkeyRecorderView.swift in Sources */,
6A18F6635B509FF58669F505 /* HotkeySettingsView.swift in Sources */,
12F68EDA880030DAB644FF5F /* LaunchAtLoginHelper.swift in Sources */,
A74E14B8AD19C53820853D8E /* NSScreen+Extensions.swift in Sources */,
EE72479BA5A25FF31BACCC50 /* NotchOrchestrator.swift in Sources */,
3B69CB3CDEC2E5F2DCE600F9 /* NotchSettings.swift in Sources */,
088FE22B3308AAB25027E086 /* NotchShape.swift in Sources */,
23E2DDCF36D0DAB2EA72C39C /* NotchState.swift in Sources */,
4E0AD14B7427532271E485AA /* NotchWindow.swift in Sources */,
D4CEB4B895F75D91FA892A06 /* PopoutWindowController.swift in Sources */,
787477BB6D2F8AC723EEDA7D /* ScreenContext.swift in Sources */,
F8ED522FDC96B1F9AD8933F7 /* ScreenManager.swift in Sources */,
E63FD5862C0EC54E284F6A0F /* ScreenRegistry.swift in Sources */,
771088361F981A9AAE976F3C /* SettingsBindings.swift in Sources */,
DAD3AB4A0DAADA32C02D959E /* SettingsView.swift in Sources */,
5A3FDFCF30A1AAFE070290E9 /* SettingsWindowController.swift in Sources */,
5F3534F66A5DBE4E081AFFA6 /* SwiftTermView.swift in Sources */,
88113BA9B217DA579C36BEBE /* TabBar.swift in Sources */,
DCFD5B03E64A46783F46726B /* TerminalCommandArrowBehavior.swift in Sources */,
6F249EDFA2D654457DF385F1 /* TerminalManager.swift in Sources */,
7A69B5AAC686174BCA54D0F0 /* TerminalSession.swift in Sources */,
65C7DB7296C6C6A77598A1F4 /* TerminalSettingsView.swift in Sources */,
1AB4A0F1BE668D3130EFBA93 /* TerminalTheme.swift in Sources */,
507A67E770DEFAF5BC321FCF /* WindowCoordinator.swift in Sources */,
86BB4A41E8EC44A5F45CE995 /* WorkspaceController.swift in Sources */,
4CFA0C79095ACE0A327A2469 /* WorkspaceRegistry.swift in Sources */,
AE8215538D4B026A00BAD241 /* WorkspaceStore.swift in Sources */,
2DF22798D3A7514E2A9183FC /* WorkspaceSummary.swift in Sources */,
6624A60CB121A22A03365071 /* WorkspaceSwitcherView.swift in Sources */,
26AE379040149CBE05B314BB /* WorkspacesSettingsView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
316871D68B87C00F5A8FEECC /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = D5585E5732CD067DF6EF0C69 /* CommandNotch */;
targetProxy = 13E720E97D079D298D124BBE /* PBXContainerItemProxy */;
};
F9E4A521E345300B08E257EA /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = D5585E5732CD067DF6EF0C69 /* CommandNotch */;
targetProxy = 9D6387038E18398CA764147F /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
15F1D354AC7D5666A8317E25 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = CommandNotch/Resources/CommandNotch.entitlements;
COMBINE_HIDPI_IMAGES = YES;
INFOPLIST_FILE = CommandNotch/Resources/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.commandnotch.app;
PRODUCT_NAME = CommandNotch;
SDKROOT = macosx;
};
name = Debug;
};
4C713B8FE8B6293021AB974F /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
COMBINE_HIDPI_IMAGES = YES;
GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
"@loader_path/../Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.commandnotch.CommandNotchTests;
PRODUCT_NAME = CommandNotchTests;
SDKROOT = macosx;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/CommandNotch.app/Contents/MacOS/CommandNotch";
};
name = Release;
};
7D032F3A06E313F1F92D39EC /* 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;
};
860E4EAD454534A04683E7DD /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = CommandNotch/Resources/CommandNotch.entitlements;
COMBINE_HIDPI_IMAGES = YES;
INFOPLIST_FILE = CommandNotch/Resources/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.commandnotch.app;
PRODUCT_NAME = CommandNotch;
SDKROOT = macosx;
};
name = Release;
};
87EEF9DE40EE78121DC1E68B /* 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;
};
C9CA7CD89BF1C9A9BC98C4CF /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
COMBINE_HIDPI_IMAGES = YES;
DEVELOPMENT_TEAM = G698BP272N;
GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.commandnotch.CommandNotchUITests;
PRODUCT_NAME = CommandNotchUITests;
SDKROOT = macosx;
TEST_TARGET_NAME = CommandNotch;
};
name = Debug;
};
E52824EDC7F4752F43B6301A /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
COMBINE_HIDPI_IMAGES = YES;
GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
"@loader_path/../Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.commandnotch.CommandNotchTests;
PRODUCT_NAME = CommandNotchTests;
SDKROOT = macosx;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/CommandNotch.app/Contents/MacOS/CommandNotch";
};
name = Debug;
};
F697F3FF95C1EB110FC25A5C /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
COMBINE_HIDPI_IMAGES = YES;
DEVELOPMENT_TEAM = G698BP272N;
GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.commandnotch.CommandNotchUITests;
PRODUCT_NAME = CommandNotchUITests;
SDKROOT = macosx;
TEST_TARGET_NAME = CommandNotch;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
983C618ACECB88BD023F005E /* Build configuration list for PBXNativeTarget "CommandNotchTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
E52824EDC7F4752F43B6301A /* Debug */,
4C713B8FE8B6293021AB974F /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
C47A3896770C98F2A3E62B7A /* Build configuration list for PBXProject "CommandNotch" */ = {
isa = XCConfigurationList;
buildConfigurations = (
7D032F3A06E313F1F92D39EC /* Debug */,
87EEF9DE40EE78121DC1E68B /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
CEAE976FCC3F5BD54E57E585 /* Build configuration list for PBXNativeTarget "CommandNotchUITests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
C9CA7CD89BF1C9A9BC98C4CF /* Debug */,
F697F3FF95C1EB110FC25A5C /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
E599EC932C075AF0FD763A0E /* Build configuration list for PBXNativeTarget "CommandNotch" */ = {
isa = XCConfigurationList;
buildConfigurations = (
15F1D354AC7D5666A8317E25 /* Debug */,
860E4EAD454534A04683E7DD /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
28377BE3F9997892D4929B6E /* XCRemoteSwiftPackageReference "SwiftTerm" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/migueldeicaza/SwiftTerm.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 1.2.0;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
C921E6435A64AA07A0FEA4D5 /* SwiftTerm */ = {
isa = XCSwiftPackageProductDependency;
package = 28377BE3F9997892D4929B6E /* XCRemoteSwiftPackageReference "SwiftTerm" */;
productName = SwiftTerm;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = E99C2EDB39F7B64C1540BCA8 /* Project object */;
}

View File

@@ -1,11 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "2620" LastUpgradeVersion = "1600"
version = "1.7"> version = "1.7">
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"
buildImplicitDependencies = "YES" buildImplicitDependencies = "YES"
buildArchitectures = "Automatic"> runPostActionsOnFailure = "NO">
<BuildActionEntries> <BuildActionEntries>
<BuildActionEntry <BuildActionEntry
buildForTesting = "YES" buildForTesting = "YES"
@@ -15,7 +15,7 @@
buildForAnalyzing = "YES"> buildForAnalyzing = "YES">
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "1485207FA11756EC2DF4F08B" BlueprintIdentifier = "D5585E5732CD067DF6EF0C69"
BuildableName = "CommandNotch.app" BuildableName = "CommandNotch.app"
BlueprintName = "CommandNotch" BlueprintName = "CommandNotch"
ReferencedContainer = "container:CommandNotch.xcodeproj"> ReferencedContainer = "container:CommandNotch.xcodeproj">
@@ -28,7 +28,42 @@
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES" shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES"> onlyGenerateCoverageForSpecifiedTargets = "NO">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D5585E5732CD067DF6EF0C69"
BuildableName = "CommandNotch.app"
BlueprintName = "CommandNotch"
ReferencedContainer = "container:CommandNotch.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "036FDAECD12C0A679DA1F5D6"
BuildableName = "CommandNotchTests.xctest"
BlueprintName = "CommandNotchTests"
ReferencedContainer = "container:CommandNotch.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO"
parallelizable = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1C8D00CBB29219BD347E9CC4"
BuildableName = "CommandNotchUITests.xctest"
BlueprintName = "CommandNotchUITests"
ReferencedContainer = "container:CommandNotch.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
<CommandLineArguments>
</CommandLineArguments>
</TestAction> </TestAction>
<LaunchAction <LaunchAction
buildConfiguration = "Debug" buildConfiguration = "Debug"
@@ -44,12 +79,14 @@
runnableDebuggingMode = "0"> runnableDebuggingMode = "0">
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "1485207FA11756EC2DF4F08B" BlueprintIdentifier = "D5585E5732CD067DF6EF0C69"
BuildableName = "CommandNotch.app" BuildableName = "CommandNotch.app"
BlueprintName = "CommandNotch" BlueprintName = "CommandNotch"
ReferencedContainer = "container:CommandNotch.xcodeproj"> ReferencedContainer = "container:CommandNotch.xcodeproj">
</BuildableReference> </BuildableReference>
</BuildableProductRunnable> </BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</LaunchAction> </LaunchAction>
<ProfileAction <ProfileAction
buildConfiguration = "Release" buildConfiguration = "Release"
@@ -61,12 +98,14 @@
runnableDebuggingMode = "0"> runnableDebuggingMode = "0">
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "1485207FA11756EC2DF4F08B" BlueprintIdentifier = "D5585E5732CD067DF6EF0C69"
BuildableName = "CommandNotch.app" BuildableName = "CommandNotch.app"
BlueprintName = "CommandNotch" BlueprintName = "CommandNotch"
ReferencedContainer = "container:CommandNotch.xcodeproj"> ReferencedContainer = "container:CommandNotch.xcodeproj">
</BuildableReference> </BuildableReference>
</BuildableProductRunnable> </BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</ProfileAction> </ProfileAction>
<AnalyzeAction <AnalyzeAction
buildConfiguration = "Debug"> buildConfiguration = "Debug">

View File

@@ -1,11 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "2620" LastUpgradeVersion = "1600"
version = "1.7"> version = "1.7">
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"
buildImplicitDependencies = "YES" buildImplicitDependencies = "YES"
buildArchitectures = "Automatic"> runPostActionsOnFailure = "NO">
<BuildActionEntries> <BuildActionEntries>
<BuildActionEntry <BuildActionEntry
buildForTesting = "YES" buildForTesting = "YES"
@@ -15,7 +15,7 @@
buildForAnalyzing = "YES"> buildForAnalyzing = "YES">
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "1485207FA11756EC2DF4F08B" BlueprintIdentifier = "D5585E5732CD067DF6EF0C69"
BuildableName = "CommandNotch.app" BuildableName = "CommandNotch.app"
BlueprintName = "CommandNotch" BlueprintName = "CommandNotch"
ReferencedContainer = "container:CommandNotch.xcodeproj"> ReferencedContainer = "container:CommandNotch.xcodeproj">
@@ -28,13 +28,48 @@
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES" shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES"> onlyGenerateCoverageForSpecifiedTargets = "NO">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D5585E5732CD067DF6EF0C69"
BuildableName = "CommandNotch.app"
BlueprintName = "CommandNotch"
ReferencedContainer = "container:CommandNotch.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "036FDAECD12C0A679DA1F5D6"
BuildableName = "CommandNotchTests.xctest"
BlueprintName = "CommandNotchTests"
ReferencedContainer = "container:CommandNotch.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO"
parallelizable = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1C8D00CBB29219BD347E9CC4"
BuildableName = "CommandNotchUITests.xctest"
BlueprintName = "CommandNotchUITests"
ReferencedContainer = "container:CommandNotch.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
<CommandLineArguments>
</CommandLineArguments>
</TestAction> </TestAction>
<LaunchAction <LaunchAction
buildConfiguration = "Release" buildConfiguration = "Release"
selectedDebuggerIdentifier = "" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "1" launchStyle = "0"
useCustomWorkingDirectory = "NO" useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO" ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES" debugDocumentVersioning = "YES"
@@ -44,12 +79,14 @@
runnableDebuggingMode = "0"> runnableDebuggingMode = "0">
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "1485207FA11756EC2DF4F08B" BlueprintIdentifier = "D5585E5732CD067DF6EF0C69"
BuildableName = "CommandNotch.app" BuildableName = "CommandNotch.app"
BlueprintName = "CommandNotch" BlueprintName = "CommandNotch"
ReferencedContainer = "container:CommandNotch.xcodeproj"> ReferencedContainer = "container:CommandNotch.xcodeproj">
</BuildableReference> </BuildableReference>
</BuildableProductRunnable> </BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</LaunchAction> </LaunchAction>
<ProfileAction <ProfileAction
buildConfiguration = "Release" buildConfiguration = "Release"
@@ -61,12 +98,14 @@
runnableDebuggingMode = "0"> runnableDebuggingMode = "0">
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "1485207FA11756EC2DF4F08B" BlueprintIdentifier = "D5585E5732CD067DF6EF0C69"
BuildableName = "CommandNotch.app" BuildableName = "CommandNotch.app"
BlueprintName = "CommandNotch" BlueprintName = "CommandNotch"
ReferencedContainer = "container:CommandNotch.xcodeproj"> ReferencedContainer = "container:CommandNotch.xcodeproj">
</BuildableReference> </BuildableReference>
</BuildableProductRunnable> </BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</ProfileAction> </ProfileAction>
<AnalyzeAction <AnalyzeAction
buildConfiguration = "Debug"> buildConfiguration = "Debug">

View File

@@ -9,6 +9,16 @@
<key>orderHint</key> <key>orderHint</key>
<integer>0</integer> <integer>0</integer>
</dict> </dict>
<key>CommandNotchTests.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>2</integer>
</dict>
<key>CommandNotchUITests.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>2</integer>
</dict>
<key>Release-CommandNotch.xcscheme_^#shared#^_</key> <key>Release-CommandNotch.xcscheme_^#shared#^_</key>
<dict> <dict>
<key>orderHint</key> <key>orderHint</key>

View File

@@ -0,0 +1,117 @@
import AppKit
import Combine
/// Application delegate that bootstraps the notch overlay system.
@MainActor
class AppDelegate: NSObject, NSApplicationDelegate {
private enum UITestLaunchArgument {
static let regularActivation = "--uitest-regular-activation"
static let showSettings = "--uitest-show-settings"
static let openNotch = "--uitest-open-notch"
}
private var cancellables = Set<AnyCancellable>()
private let settingsController = AppSettingsController.shared
func applicationDidFinishLaunching(_ notification: Notification) {
NotchSettings.registerDefaults()
if isRunningUITests {
NSApp.setActivationPolicy(.regular)
} else {
NSApp.setActivationPolicy(.accessory)
}
// Sync the launch-at-login toggle with the actual system state
// in case the user toggled it from System Settings.
settingsController.update {
$0.display.launchAtLogin = LaunchAtLoginHelper.isEnabled
}
ScreenManager.shared.start()
observeDisplayPreference()
observeSizePreferences()
observeFontSizeChanges()
observeTerminalThemeChanges()
applyUITestLaunchBehaviorIfNeeded()
}
func applicationWillTerminate(_ notification: Notification) {
ScreenManager.shared.stop()
}
// MARK: - Preference observers
/// Only rebuild windows when the display-count preference changes.
private func observeDisplayPreference() {
settingsController.$settings
.map(\.display.showOnAllDisplays)
.removeDuplicates()
.dropFirst()
.sink { _ in
ScreenManager.shared.rebuildWindows()
}
.store(in: &cancellables)
}
/// Reposition (not rebuild) when any sizing preference changes.
private func observeSizePreferences() {
settingsController.$settings
.map(\.display.layoutSignature)
.removeDuplicates()
.dropFirst()
.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() {
settingsController.$settings
.map(\.terminal.fontSize)
.removeDuplicates()
.sink { newSize in
guard newSize > 0 else { return }
WorkspaceRegistry.shared.updateAllWorkspacesFontSizes(CGFloat(newSize))
}
.store(in: &cancellables)
}
/// Live-update terminal colors across all sessions.
private func observeTerminalThemeChanges() {
settingsController.$settings
.map(\.terminal.themeRawValue)
.removeDuplicates()
.sink { newTheme in
WorkspaceRegistry.shared.updateAllWorkspacesThemes(TerminalTheme.resolve(newTheme))
}
.store(in: &cancellables)
}
private var launchArguments: [String] {
ProcessInfo.processInfo.arguments
}
private var isRunningUITests: Bool {
launchArguments.contains(UITestLaunchArgument.regularActivation)
|| launchArguments.contains(UITestLaunchArgument.showSettings)
|| launchArguments.contains(UITestLaunchArgument.openNotch)
}
private func applyUITestLaunchBehaviorIfNeeded() {
guard isRunningUITests else { return }
DispatchQueue.main.async { @MainActor in
if self.launchArguments.contains(UITestLaunchArgument.showSettings) {
SettingsWindowController.shared.showSettings()
}
if self.launchArguments.contains(UITestLaunchArgument.openNotch),
let screenID = ScreenRegistry.shared.activeScreenID() {
ScreenManager.shared.openNotch(screenID: screenID)
}
}
}
}

View File

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

View File

@@ -76,9 +76,7 @@ struct HotkeyRecorderField: NSViewRepresentable {
} }
func updateNSView(_ nsView: HotkeyNSView, context: Context) { func updateNSView(_ nsView: HotkeyNSView, context: Context) {
nsView.currentLabel = binding.displayString nsView.update(currentLabel: binding.displayString, isRecording: isRecording)
nsView.showRecording = isRecording
nsView.needsDisplay = true
} }
} }
@@ -99,9 +97,7 @@ struct OptionalHotkeyRecorderField: NSViewRepresentable {
} }
func updateNSView(_ nsView: HotkeyNSView, context: Context) { func updateNSView(_ nsView: HotkeyNSView, context: Context) {
nsView.currentLabel = binding?.displayString ?? "Not set" nsView.update(currentLabel: binding?.displayString ?? "Not set", isRecording: isRecording)
nsView.showRecording = isRecording
nsView.needsDisplay = true
} }
} }
@@ -183,6 +179,12 @@ class HotkeyNSView: NSView {
updateLabelAppearance() updateLabelAppearance()
} }
func update(currentLabel: String, isRecording: Bool) {
self.currentLabel = currentLabel
showRecording = isRecording
updateLabelAppearance()
}
private func updateLabelAppearance() { private func updateLabelAppearance() {
label.stringValue = showRecording ? "Press keys..." : currentLabel label.stringValue = showRecording ? "Press keys..." : currentLabel
label.textColor = showRecording ? .controlAccentColor : .labelColor label.textColor = showRecording ? .controlAccentColor : .labelColor

View File

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

View File

@@ -0,0 +1,31 @@
import AppKit
import Carbon.HIToolbox
import SwiftTerm
enum TerminalCommandArrowBehavior {
private static let relevantModifiers: NSEvent.ModifierFlags = [.command, .control, .option, .shift]
private static let lineKill: [UInt8] = [0x15]
private static let clearScreen: [UInt8] = [0x0c]
static func sequence(
for modifierFlags: NSEvent.ModifierFlags,
keyCode: UInt16,
applicationCursor: Bool
) -> [UInt8]? {
let flags = modifierFlags.intersection(relevantModifiers)
guard flags == [.command] else { return nil }
switch Int(keyCode) {
case kVK_LeftArrow:
return applicationCursor ? EscapeSequences.moveHomeApp : EscapeSequences.moveHomeNormal
case kVK_RightArrow:
return applicationCursor ? EscapeSequences.moveEndApp : EscapeSequences.moveEndNormal
case kVK_Delete:
return lineKill
case kVK_ANSI_L:
return clearScreen
default:
return nil
}
}
}

View File

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

View File

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

View File

@@ -1,11 +1,13 @@
import AppKit import AppKit
import Carbon.HIToolbox import Carbon.HIToolbox
import Combine
/// Manages global and local hotkeys. /// Manages global and local hotkeys.
/// ///
/// The toggle hotkey uses Carbon's `RegisterEventHotKey` which works /// The toggle hotkey uses Carbon's `RegisterEventHotKey` which works
/// system-wide without Accessibility permission. Tab-level hotkeys /// system-wide without Accessibility permission. Notch-scoped hotkeys
/// use a local `NSEvent` monitor (only fires when our app is active). /// use a local `NSEvent` monitor (only fires when our app is active).
@MainActor
class HotkeyManager { class HotkeyManager {
static let shared = HotkeyManager() static let shared = HotkeyManager()
@@ -17,47 +19,59 @@ class HotkeyManager {
var onCloseTab: (() -> Void)? var onCloseTab: (() -> Void)?
var onNextTab: (() -> Void)? var onNextTab: (() -> Void)?
var onPreviousTab: (() -> Void)? var onPreviousTab: (() -> Void)?
var onNextWorkspace: (() -> Void)?
var onPreviousWorkspace: (() -> Void)?
var onDetachTab: (() -> Void)? var onDetachTab: (() -> Void)?
var onApplySizePreset: ((TerminalSizePreset) -> Void)? var onApplySizePreset: ((TerminalSizePreset) -> Void)?
var onSwitchToTab: ((Int) -> Void)? var onSwitchToTab: ((Int) -> Void)?
var onSwitchToWorkspace: ((WorkspaceID) -> Void)?
/// Tab-level hotkeys only fire when the notch is open. /// Notch-scoped hotkeys only fire when the notch is open.
var isNotchOpen: Bool = false var isNotchOpen: Bool = false
private var hotKeyRef: EventHotKeyRef? private var hotKeyRef: EventHotKeyRef?
private var eventHandlerRef: EventHandlerRef? private var eventHandlerRef: EventHandlerRef?
private var localMonitor: Any? private var localMonitor: Any?
private var defaultsObserver: NSObjectProtocol? private let settingsProvider: TerminalSessionConfigurationProviding
private let workspaceRegistry: WorkspaceRegistry
private var settingsCancellable: AnyCancellable?
private init() {} init(
settingsProvider: TerminalSessionConfigurationProviding? = nil,
workspaceRegistry: WorkspaceRegistry? = nil
) {
self.settingsProvider = settingsProvider ?? AppSettingsController.shared
self.workspaceRegistry = workspaceRegistry ?? WorkspaceRegistry.shared
}
// MARK: - Resolved bindings (live from UserDefaults) // MARK: - Resolved bindings from typed runtime settings
private var toggleBinding: HotkeyBinding { private var toggleBinding: HotkeyBinding {
binding(for: NotchSettings.Keys.hotkeyToggle) ?? .cmdReturn settingsProvider.hotkeySettings.toggle
} }
private var newTabBinding: HotkeyBinding { private var newTabBinding: HotkeyBinding {
binding(for: NotchSettings.Keys.hotkeyNewTab) ?? .cmdT settingsProvider.hotkeySettings.newTab
} }
private var closeTabBinding: HotkeyBinding { private var closeTabBinding: HotkeyBinding {
binding(for: NotchSettings.Keys.hotkeyCloseTab) ?? .cmdW settingsProvider.hotkeySettings.closeTab
} }
private var nextTabBinding: HotkeyBinding { private var nextTabBinding: HotkeyBinding {
binding(for: NotchSettings.Keys.hotkeyNextTab) ?? .cmdShiftRB settingsProvider.hotkeySettings.nextTab
} }
private var prevTabBinding: HotkeyBinding { private var prevTabBinding: HotkeyBinding {
binding(for: NotchSettings.Keys.hotkeyPreviousTab) ?? .cmdShiftLB settingsProvider.hotkeySettings.previousTab
}
private var nextWorkspaceBinding: HotkeyBinding {
settingsProvider.hotkeySettings.nextWorkspace
}
private var previousWorkspaceBinding: HotkeyBinding {
settingsProvider.hotkeySettings.previousWorkspace
} }
private var detachBinding: HotkeyBinding { private var detachBinding: HotkeyBinding {
binding(for: NotchSettings.Keys.hotkeyDetachTab) ?? .cmdD settingsProvider.hotkeySettings.detachTab
} }
private var sizePresets: [TerminalSizePreset] { private var sizePresets: [TerminalSizePreset] {
TerminalSizePresetStore.load() settingsProvider.terminalSizePresets
}
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 // MARK: - Start / Stop
@@ -73,10 +87,7 @@ class HotkeyManager {
unregisterToggleHotkey() unregisterToggleHotkey()
removeCarbonHandler() removeCarbonHandler()
removeLocalMonitor() removeLocalMonitor()
if let obs = defaultsObserver { settingsCancellable = nil
NotificationCenter.default.removeObserver(obs)
defaultsObserver = nil
}
} }
// MARK: - Carbon global hotkey (toggle) // MARK: - Carbon global hotkey (toggle)
@@ -130,7 +141,7 @@ class HotkeyManager {
let binding = toggleBinding let binding = toggleBinding
let carbonMods = carbonModifiers(from: binding.modifiers) let carbonMods = carbonModifiers(from: binding.modifiers)
var hotKeyID = EventHotKeyID( let hotKeyID = EventHotKeyID(
signature: OSType(0x444E5452), // "DNTR" signature: OSType(0x444E5452), // "DNTR"
id: 1 id: 1
) )
@@ -163,18 +174,20 @@ class HotkeyManager {
} }
} }
/// Re-register the toggle hotkey whenever the user changes it in settings. /// Re-register the toggle hotkey whenever the typed settings change.
private func observeToggleHotkeyChanges() { private func observeToggleHotkeyChanges() {
defaultsObserver = NotificationCenter.default.addObserver( guard let settingsProvider = settingsProvider as? AppSettingsController else { return }
forName: UserDefaults.didChangeNotification,
object: nil, settingsCancellable = settingsProvider.$settings
queue: .main .map(\.hotkeys.toggle)
) { [weak self] _ in .removeDuplicates()
.dropFirst()
.sink { [weak self] _ in
self?.registerToggleHotkey() self?.registerToggleHotkey()
} }
} }
// MARK: - Local monitor (tab-level hotkeys, only when our app is active) // MARK: - Local monitor (notch-level hotkeys, only when our app is active)
private func installLocalMonitor() { private func installLocalMonitor() {
localMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in localMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
@@ -190,9 +203,9 @@ class HotkeyManager {
} }
} }
/// Handles tab-level hotkeys. Returns true if the event was consumed. /// Handles notch-scoped hotkeys. Returns true if the event was consumed.
private func handleLocalKeyEvent(_ event: NSEvent) -> Bool { private func handleLocalKeyEvent(_ event: NSEvent) -> Bool {
// Tab hotkeys only when the notch is open and focused // Local shortcuts only fire when the notch is open and focused.
guard isNotchOpen else { return false } guard isNotchOpen else { return false }
if newTabBinding.matches(event) { if newTabBinding.matches(event) {
@@ -211,10 +224,25 @@ class HotkeyManager {
onPreviousTab?() onPreviousTab?()
return true return true
} }
if nextWorkspaceBinding.matches(event) {
onNextWorkspace?()
return true
}
if previousWorkspaceBinding.matches(event) {
onPreviousWorkspace?()
return true
}
if detachBinding.matches(event) { if detachBinding.matches(event) {
onDetachTab?() onDetachTab?()
return true return true
} }
for summary in workspaceRegistry.workspaceSummaries {
guard let binding = summary.hotkey else { continue }
if binding.matches(event) {
onSwitchToWorkspace?(summary.id)
return true
}
}
for preset in sizePresets { for preset in sizePresets {
guard let binding = preset.hotkey else { continue } guard let binding = preset.hotkey else { continue }
if binding.matches(event) { if binding.matches(event) {

View File

@@ -0,0 +1,297 @@
import AppKit
import Combine
import SwiftUI
/// Coordinates screen/workspace state with notch lifecycle and
/// delegates raw window work to `WindowCoordinator`.
@MainActor
final class ScreenManager: ObservableObject {
static let shared = ScreenManager()
private let screenRegistry = ScreenRegistry.shared
private let workspaceRegistry = WorkspaceRegistry.shared
private let windowCoordinator = WindowCoordinator()
private lazy var orchestrator = NotchOrchestrator(screenRegistry: screenRegistry, host: self)
private var cancellables = Set<AnyCancellable>()
private init() {}
private var showOnAllDisplays: Bool {
AppSettingsController.shared.settings.display.showOnAllDisplays
}
// MARK: - Lifecycle
func start() {
screenRegistry.refreshConnectedScreens()
observeScreenChanges()
rebuildWindows()
setupHotkeys()
}
func stop() {
cleanupAllWindows()
cancellables.removeAll()
HotkeyManager.shared.stop()
}
// MARK: - Hotkey wiring
private func setupHotkeys() {
let hotkeyManager = HotkeyManager.shared
hotkeyManager.onToggle = { [weak self] in
MainActor.assumeIsolated { self?.orchestrator.toggleOnActiveScreen() }
}
hotkeyManager.onNewTab = { [weak self] in
MainActor.assumeIsolated { self?.activeWorkspace().newTab() }
}
hotkeyManager.onCloseTab = { [weak self] in
MainActor.assumeIsolated { self?.activeWorkspace().closeActiveTab() }
}
hotkeyManager.onNextTab = { [weak self] in
MainActor.assumeIsolated { self?.activeWorkspace().nextTab() }
}
hotkeyManager.onPreviousTab = { [weak self] in
MainActor.assumeIsolated { self?.activeWorkspace().previousTab() }
}
hotkeyManager.onNextWorkspace = { [weak self] in
MainActor.assumeIsolated { self?.switchWorkspace(offset: 1) }
}
hotkeyManager.onPreviousWorkspace = { [weak self] in
MainActor.assumeIsolated { self?.switchWorkspace(offset: -1) }
}
hotkeyManager.onDetachTab = { [weak self] in
MainActor.assumeIsolated { self?.detachActiveTab() }
}
hotkeyManager.onApplySizePreset = { [weak self] preset in
MainActor.assumeIsolated { self?.applySizePreset(preset) }
}
hotkeyManager.onSwitchToTab = { [weak self] index in
MainActor.assumeIsolated { self?.activeWorkspace().switchToTab(at: index) }
}
hotkeyManager.onSwitchToWorkspace = { [weak self] workspaceID in
MainActor.assumeIsolated { self?.switchActiveScreen(to: workspaceID) }
}
hotkeyManager.start()
}
// MARK: - Toggle
func toggleNotchOnActiveScreen() {
orchestrator.toggleOnActiveScreen()
}
// MARK: - Open / Close
func openNotch(screenID: ScreenID) {
orchestrator.open(screenID: screenID)
}
func closeNotch(screenID: ScreenID) {
orchestrator.close(screenID: screenID)
}
private func detachActiveTab() {
if let session = activeWorkspace().detachActiveTab() {
DispatchQueue.main.async {
PopoutWindowController.shared.popout(session: session)
}
}
}
private func switchWorkspace(offset: Int) {
guard let screenID = screenRegistry.activeScreenID() else { return }
let currentWorkspaceID = screenRegistry.screenContext(for: screenID)?.workspaceID ?? workspaceRegistry.defaultWorkspaceID
let nextWorkspaceID = offset >= 0
? workspaceRegistry.nextWorkspaceID(after: currentWorkspaceID)
: workspaceRegistry.previousWorkspaceID(before: currentWorkspaceID)
guard let nextWorkspaceID else { return }
switchScreen(screenID, to: nextWorkspaceID)
}
private func switchActiveScreen(to workspaceID: WorkspaceID) {
guard let screenID = screenRegistry.activeScreenID() else { return }
switchScreen(screenID, to: workspaceID)
}
private func switchScreen(_ screenID: ScreenID, to workspaceID: WorkspaceID) {
screenRegistry.assignWorkspace(workspaceID, to: screenID)
guard let context = screenRegistry.screenContext(for: screenID),
context.notchState == .open else {
return
}
orchestrator.open(screenID: screenID)
}
func applySizePreset(_ preset: TerminalSizePreset) {
guard let context = screenRegistry.allScreens().first(where: { $0.notchState == .open }) else {
AppSettingsController.shared.update {
$0.display.openWidth = preset.width
$0.display.openHeight = preset.height
}
return
}
let startSize = context.notchSize
let targetSize = context.setStoredOpenSize(preset.size)
windowCoordinator.animatePresetResize(
for: context.id,
context: context,
from: startSize,
to: targetSize,
duration: context.openAnimationDuration
)
}
// MARK: - Window creation
func rebuildWindows() {
cleanupAllWindows()
screenRegistry.refreshConnectedScreens()
for screen in visibleScreens() {
createWindow(for: screen)
}
}
private func createWindow(for screen: NSScreen) {
let screenID = screen.displayUUID
guard let context = screenRegistry.screenContext(for: screenID) else { return }
context.requestOpen = { [weak self] in
self?.orchestrator.open(screenID: screenID)
}
context.requestClose = { [weak self] in
self?.orchestrator.close(screenID: screenID)
}
context.requestWindowResize = { [weak self] in
guard let self,
let context = self.screenRegistry.screenContext(for: screenID) else {
return
}
self.windowCoordinator.updateWindowFrame(
for: screenID,
context: context,
centerHorizontally: true
)
}
context.requestTerminalFocus = { [weak self] in
guard let self else { return }
self.windowCoordinator.focusActiveTerminal(for: screenID) { [weak self] in
self?.screenRegistry.workspaceController(for: screenID).activeTab?.terminalView
}
}
let hostingView = NSHostingView(
rootView: ContentView(
screen: context,
orchestrator: orchestrator
)
.preferredColorScheme(.dark)
)
windowCoordinator.createWindow(
on: screen,
context: context,
contentView: hostingView,
onResignKey: { [weak self] in
guard !context.suppressCloseOnFocusLoss else { return }
self?.orchestrator.close(screenID: screenID)
}
)
}
// MARK: - Repositioning
func repositionWindows() {
screenRegistry.refreshConnectedScreens()
for context in screenRegistry.allScreens() {
context.refreshClosedSize()
windowCoordinator.repositionWindow(
for: context.id,
context: context,
centerHorizontally: true
)
}
}
// MARK: - Cleanup
private func cleanupAllWindows() {
orchestrator.cancelAllPendingWork()
windowCoordinator.cleanupAllWindows()
}
// 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() {
screenRegistry.refreshConnectedScreens()
let currentScreenIDs = Set(visibleScreens().map(\.displayUUID))
let knownScreenIDs = windowCoordinator.windowScreenIDs()
if currentScreenIDs != knownScreenIDs {
rebuildWindows()
} else {
repositionWindows()
}
}
private func activeWorkspace() -> WorkspaceController {
guard let screenID = screenRegistry.activeScreenID() else {
return WorkspaceRegistry.shared.defaultWorkspaceController
}
return screenRegistry.workspaceController(for: screenID)
}
private func visibleScreens() -> [NSScreen] {
if showOnAllDisplays {
return NSScreen.screens
}
return [NSScreen.main].compactMap { $0 }
}
}
extension ScreenManager: NotchPresentationHost {
func canPresentNotch(for screenID: ScreenID) -> Bool {
windowCoordinator.hasWindow(for: screenID)
}
func performOpenPresentation(for screenID: ScreenID) {
guard screenRegistry.screenContext(for: screenID) != nil else {
return
}
windowCoordinator.presentOpen(for: screenID) { [weak self] in
self?.screenRegistry.workspaceController(for: screenID).activeTab?.terminalView
}
}
func performClosePresentation(for screenID: ScreenID) {
guard screenRegistry.screenContext(for: screenID) != nil else {
return
}
windowCoordinator.presentClose(for: screenID)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -88,6 +88,8 @@ struct HotkeyBinding: Codable, Equatable, Hashable {
static let cmdW = HotkeyBinding(modifiers: NSEvent.ModifierFlags.command.rawValue, keyCode: 13) 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 cmdShiftRB = HotkeyBinding(modifiers: NSEvent.ModifierFlags([.command, .shift]).rawValue, keyCode: 30) // ]
static let cmdShiftLB = HotkeyBinding(modifiers: NSEvent.ModifierFlags([.command, .shift]).rawValue, keyCode: 33) // [ static let cmdShiftLB = HotkeyBinding(modifiers: NSEvent.ModifierFlags([.command, .shift]).rawValue, keyCode: 33) // [
static let cmdShiftDown = HotkeyBinding(modifiers: NSEvent.ModifierFlags([.command, .shift]).rawValue, keyCode: 125)
static let cmdShiftUp = HotkeyBinding(modifiers: NSEvent.ModifierFlags([.command, .shift]).rawValue, keyCode: 126)
static let cmdD = HotkeyBinding(modifiers: NSEvent.ModifierFlags.command.rawValue, keyCode: 2) static let cmdD = HotkeyBinding(modifiers: NSEvent.ModifierFlags.command.rawValue, keyCode: 2)
static func cmdShiftDigit(_ digit: Int) -> HotkeyBinding? { static func cmdShiftDigit(_ digit: Int) -> HotkeyBinding? {

View File

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

View File

@@ -48,6 +48,8 @@ enum NotchSettings {
static let terminalShell = "terminalShell" static let terminalShell = "terminalShell"
static let terminalTheme = "terminalTheme" static let terminalTheme = "terminalTheme"
static let terminalSizePresets = "terminalSizePresets" static let terminalSizePresets = "terminalSizePresets"
static let workspaceSummaries = "workspaceSummaries"
static let screenAssignments = "screenAssignments"
// Hotkeys each stores a HotkeyBinding JSON string // Hotkeys each stores a HotkeyBinding JSON string
static let hotkeyToggle = "hotkey_toggle" static let hotkeyToggle = "hotkey_toggle"
@@ -55,6 +57,8 @@ enum NotchSettings {
static let hotkeyCloseTab = "hotkey_closeTab" static let hotkeyCloseTab = "hotkey_closeTab"
static let hotkeyNextTab = "hotkey_nextTab" static let hotkeyNextTab = "hotkey_nextTab"
static let hotkeyPreviousTab = "hotkey_previousTab" static let hotkeyPreviousTab = "hotkey_previousTab"
static let hotkeyNextWorkspace = "hotkey_nextWorkspace"
static let hotkeyPreviousWorkspace = "hotkey_previousWorkspace"
static let hotkeyDetachTab = "hotkey_detachTab" static let hotkeyDetachTab = "hotkey_detachTab"
} }
@@ -102,6 +106,8 @@ enum NotchSettings {
static let hotkeyCloseTab: String = HotkeyBinding.cmdW.toJSON() static let hotkeyCloseTab: String = HotkeyBinding.cmdW.toJSON()
static let hotkeyNextTab: String = HotkeyBinding.cmdShiftRB.toJSON() static let hotkeyNextTab: String = HotkeyBinding.cmdShiftRB.toJSON()
static let hotkeyPreviousTab: String = HotkeyBinding.cmdShiftLB.toJSON() static let hotkeyPreviousTab: String = HotkeyBinding.cmdShiftLB.toJSON()
static let hotkeyNextWorkspace: String = HotkeyBinding.cmdShiftDown.toJSON()
static let hotkeyPreviousWorkspace: String = HotkeyBinding.cmdShiftUp.toJSON()
static let hotkeyDetachTab: String = HotkeyBinding.cmdD.toJSON() static let hotkeyDetachTab: String = HotkeyBinding.cmdD.toJSON()
} }
@@ -149,6 +155,8 @@ enum NotchSettings {
Keys.hotkeyCloseTab: Defaults.hotkeyCloseTab, Keys.hotkeyCloseTab: Defaults.hotkeyCloseTab,
Keys.hotkeyNextTab: Defaults.hotkeyNextTab, Keys.hotkeyNextTab: Defaults.hotkeyNextTab,
Keys.hotkeyPreviousTab: Defaults.hotkeyPreviousTab, Keys.hotkeyPreviousTab: Defaults.hotkeyPreviousTab,
Keys.hotkeyNextWorkspace: Defaults.hotkeyNextWorkspace,
Keys.hotkeyPreviousWorkspace: Defaults.hotkeyPreviousWorkspace,
Keys.hotkeyDetachTab: Defaults.hotkeyDetachTab, Keys.hotkeyDetachTab: Defaults.hotkeyDetachTab,
]) ])
} }
@@ -212,17 +220,14 @@ enum TerminalSizePresetStore {
static func load() -> [TerminalSizePreset] { static func load() -> [TerminalSizePreset] {
let defaults = UserDefaults.standard let defaults = UserDefaults.standard
guard let json = defaults.string(forKey: NotchSettings.Keys.terminalSizePresets), guard let json = defaults.string(forKey: NotchSettings.Keys.terminalSizePresets),
let data = json.data(using: .utf8), let presets = decodePresets(from: json) else {
let presets = try? JSONDecoder().decode([TerminalSizePreset].self, from: data) else {
return defaultPresets() return defaultPresets()
} }
return presets return presets
} }
static func save(_ presets: [TerminalSizePreset]) { static func save(_ presets: [TerminalSizePreset]) {
guard let data = try? JSONEncoder().encode(presets), UserDefaults.standard.set(encodePresets(presets), forKey: NotchSettings.Keys.terminalSizePresets)
let json = String(data: data, encoding: .utf8) else { return }
UserDefaults.standard.set(json, forKey: NotchSettings.Keys.terminalSizePresets)
} }
static func reset() { static func reset() {
@@ -234,11 +239,7 @@ enum TerminalSizePresetStore {
} }
static func defaultPresetsJSON() -> String { static func defaultPresetsJSON() -> String {
guard let data = try? JSONEncoder().encode(defaultPresets()), encodePresets(defaultPresets())
let json = String(data: data, encoding: .utf8) else {
return "[]"
}
return json
} }
static func suggestedHotkey(for presets: [TerminalSizePreset]) -> HotkeyBinding? { static func suggestedHotkey(for presets: [TerminalSizePreset]) -> HotkeyBinding? {
@@ -259,4 +260,17 @@ enum TerminalSizePresetStore {
TerminalSizePreset(name: "Large", width: 900, height: 500, hotkey: HotkeyBinding.cmdShiftDigit(3)), TerminalSizePreset(name: "Large", width: 900, height: 500, hotkey: HotkeyBinding.cmdShiftDigit(3)),
] ]
} }
static func decodePresets(from json: String) -> [TerminalSizePreset]? {
guard let data = json.data(using: .utf8) else { return nil }
return try? JSONDecoder().decode([TerminalSizePreset].self, from: data)
}
static func encodePresets(_ presets: [TerminalSizePreset]) -> String {
guard let data = try? JSONEncoder().encode(presets),
let json = String(data: data, encoding: .utf8) else {
return "[]"
}
return json
}
} }

View File

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

View File

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

View File

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

View File

@@ -4,19 +4,22 @@ import Combine
/// Wraps a single SwiftTerm TerminalView + LocalProcess pair. /// Wraps a single SwiftTerm TerminalView + LocalProcess pair.
@MainActor @MainActor
class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, TerminalViewDelegate { class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, @preconcurrency TerminalViewDelegate {
let id = UUID() let id = UUID()
let terminalView: TerminalView let terminalView: TerminalView
private var process: LocalProcess? private var process: LocalProcess?
private var keyEventMonitor: Any?
private let backgroundColor = NSColor.black private let backgroundColor = NSColor.black
private let configuredShellPath: String
@Published var title: String = "shell" @Published var title: String = "shell"
@Published var isRunning: Bool = true @Published var isRunning: Bool = true
@Published var currentDirectory: String? @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)) terminalView = TerminalView(frame: NSRect(x: 0, y: 0, width: 600, height: 300))
configuredShellPath = shellPath
super.init() super.init()
terminalView.terminalDelegate = self terminalView.terminalDelegate = self
@@ -24,10 +27,17 @@ class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, Termina
let font = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular) let font = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular)
terminalView.font = font terminalView.font = font
applyTheme(theme) applyTheme(theme)
installCommandArrowMonitor()
startShell() startShell()
} }
deinit {
if let keyEventMonitor {
NSEvent.removeMonitor(keyEventMonitor)
}
}
// MARK: - Shell management // MARK: - Shell management
private func startShell() { private func startShell() {
@@ -35,27 +45,47 @@ class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, Termina
let shellName = (shellPath as NSString).lastPathComponent let shellName = (shellPath as NSString).lastPathComponent
let loginExecName = "-\(shellName)" let loginExecName = "-\(shellName)"
FileManager.default.changeCurrentDirectoryPath(NSHomeDirectory())
let proc = LocalProcess(delegate: self) let proc = LocalProcess(delegate: self)
// Launch as a login shell so user startup files initialize PATH/tools. // Launch as a login shell so user startup files initialize PATH/tools.
proc.startProcess( proc.startProcess(
executable: shellPath, executable: shellPath,
args: ["-l"], args: ["-l"],
environment: nil, environment: nil,
execName: loginExecName execName: loginExecName,
currentDirectory: NSHomeDirectory()
) )
process = proc process = proc
title = shellName title = shellName
} }
private func resolveShell() -> String { 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) { if !custom.isEmpty && FileManager.default.isExecutableFile(atPath: custom) {
return custom return custom
} }
return ProcessInfo.processInfo.environment["SHELL"] ?? "/bin/zsh" return ProcessInfo.processInfo.environment["SHELL"] ?? "/bin/zsh"
} }
private func installCommandArrowMonitor() {
keyEventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
guard let self else { return event }
guard let window = self.terminalView.window else { return event }
guard event.window === window else { return event }
guard window.firstResponder === self.terminalView else { return event }
guard let sequence = TerminalCommandArrowBehavior.sequence(
for: event.modifierFlags,
keyCode: event.keyCode,
applicationCursor: self.terminalView.getTerminal().applicationCursor
) else {
return event
}
self.terminalView.send(data: sequence[...])
return nil
}
}
func updateFontSize(_ size: CGFloat) { func updateFontSize(_ size: CGFloat) {
terminalView.font = NSFont.monospacedSystemFont(ofSize: size, weight: .regular) terminalView.font = NSFont.monospacedSystemFont(ofSize: size, weight: .regular)
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,87 @@
import SwiftUI
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)
.accessibilityIdentifier("settings.tab.\(tab.rawValue)")
}
.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 .workspaces:
WorkspacesSettingsView()
case .animation:
AnimationSettingsView()
case .terminal:
TerminalSettingsView()
case .hotkeys:
HotkeySettingsView()
case .about:
AboutSettingsView()
}
}
}
enum SettingsTab: String, CaseIterable, Identifiable {
case general, appearance, workspaces, animation, terminal, hotkeys, about
var id: String { rawValue }
var label: String {
switch self {
case .general:
"General"
case .appearance:
"Appearance"
case .workspaces:
"Workspaces"
case .animation:
"Animation"
case .terminal:
"Terminal"
case .hotkeys:
"Hotkeys"
case .about:
"About"
}
}
var icon: String {
switch self {
case .general:
"gearshape"
case .appearance:
"paintbrush"
case .workspaces:
"rectangle.3.group"
case .animation:
"bolt.fill"
case .terminal:
"terminal"
case .hotkeys:
"keyboard"
case .about:
"info.circle"
}
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,42 @@
import XCTest
@testable import CommandNotch
@MainActor
final class AppSettingsControllerTests: XCTestCase {
func testTerminalSessionConfigurationIncludesShellPath() {
let store = InMemoryAppSettingsStore()
var settings = AppSettings.default
settings.terminal.shellPath = "/opt/homebrew/bin/fish"
store.storedSettings = settings
let controller = AppSettingsController(store: store)
XCTAssertEqual(controller.terminalSessionConfiguration.shellPath, "/opt/homebrew/bin/fish")
}
func testTerminalSizePresetsDecodeFromTypedSettings() {
let store = InMemoryAppSettingsStore()
let presets = [
TerminalSizePreset(name: "Wide", width: 960, height: 420, hotkey: .cmdShiftDigit(4))
]
var settings = AppSettings.default
settings.terminal.sizePresetsJSON = TerminalSizePresetStore.encodePresets(presets)
store.storedSettings = settings
let controller = AppSettingsController(store: store)
XCTAssertEqual(controller.terminalSizePresets, presets)
}
}
private final class InMemoryAppSettingsStore: AppSettingsStoreType {
var storedSettings = AppSettings.default
func load() -> AppSettings {
storedSettings
}
func save(_ settings: AppSettings) {
storedSettings = settings
}
}

View File

@@ -0,0 +1,38 @@
import XCTest
@testable import CommandNotch
final class AppSettingsStoreTests: XCTestCase {
func testLoadReturnsDefaultValuesWhenStoreIsEmpty() {
let defaults = UserDefaults(suiteName: #function)!
defaults.removePersistentDomain(forName: #function)
defer { defaults.removePersistentDomain(forName: #function) }
let store = UserDefaultsAppSettingsStore(defaults: defaults)
XCTAssertEqual(store.load(), .default)
}
func testSaveRoundTripsSettings() {
let defaults = UserDefaults(suiteName: #function)!
defaults.removePersistentDomain(forName: #function)
defer { defaults.removePersistentDomain(forName: #function) }
let store = UserDefaultsAppSettingsStore(defaults: defaults)
var settings = AppSettings.default
settings.display.showMenuBarIcon = false
settings.display.showOnAllDisplays = false
settings.display.openWidth = 900
settings.behavior.minimumHoverDuration = 0.65
settings.appearance.blurRadius = 4.5
settings.terminal.fontSize = 16
settings.terminal.themeRawValue = TerminalTheme.dracula.rawValue
settings.terminal.sizePresetsJSON = TerminalSizePresetStore.encodePresets([
TerminalSizePreset(name: "Wide", width: 960, height: 420, hotkey: .cmdShiftDigit(4))
])
settings.hotkeys.toggle = .cmdD
store.save(settings)
XCTAssertEqual(store.load(), settings)
}
}

View File

@@ -0,0 +1,248 @@
import XCTest
import Combine
@testable import CommandNotch
@MainActor
final class NotchOrchestratorTests: XCTestCase {
func testHoverOpenSchedulesOpenAfterDelay() {
let screenID = "screen-a"
let screen = makeScreenContext(screenID: screenID)
let registry = TestScreenRegistry(activeScreenID: screenID, screens: [screen])
let host = TestNotchPresentationHost()
let scheduler = TestScheduler()
let orchestrator = NotchOrchestrator(
screenRegistry: registry,
host: host,
settingsController: makeSettingsController(),
scheduler: scheduler
)
orchestrator.handleHoverChange(true, for: screenID)
XCTAssertEqual(screen.notchState, .closed)
scheduler.runScheduledActions()
XCTAssertEqual(screen.notchState, .open)
XCTAssertEqual(host.openedScreenIDs, [screenID])
}
func testHoverExitCancelsPendingOpen() {
let screenID = "screen-a"
let screen = makeScreenContext(screenID: screenID)
let registry = TestScreenRegistry(activeScreenID: screenID, screens: [screen])
let host = TestNotchPresentationHost()
let scheduler = TestScheduler()
let orchestrator = NotchOrchestrator(
screenRegistry: registry,
host: host,
settingsController: makeSettingsController(),
scheduler: scheduler
)
orchestrator.handleHoverChange(true, for: screenID)
orchestrator.handleHoverChange(false, for: screenID)
scheduler.runScheduledActions()
XCTAssertEqual(screen.notchState, .closed)
XCTAssertTrue(host.openedScreenIDs.isEmpty)
}
func testCloseWhileHoveringSuppressesReopenUntilHoverExit() {
let screenID = "screen-a"
let screen = makeScreenContext(screenID: screenID)
let registry = TestScreenRegistry(activeScreenID: screenID, screens: [screen])
let host = TestNotchPresentationHost()
let scheduler = TestScheduler()
let orchestrator = NotchOrchestrator(
screenRegistry: registry,
host: host,
settingsController: makeSettingsController(),
scheduler: scheduler
)
orchestrator.handleHoverChange(true, for: screenID)
scheduler.runScheduledActions()
XCTAssertEqual(screen.notchState, .open)
orchestrator.handleHoverChange(true, for: screenID)
orchestrator.close(screenID: screenID)
scheduler.runScheduledActions()
XCTAssertEqual(screen.notchState, .closed)
XCTAssertFalse(screen.isCloseTransitionActive)
XCTAssertTrue(screen.suppressHoverOpenUntilHoverExit)
XCTAssertEqual(host.closedScreenIDs, [screenID])
scheduler.runScheduledActions()
XCTAssertEqual(screen.notchState, .closed)
XCTAssertEqual(host.openedScreenIDs, [screenID])
orchestrator.handleHoverChange(false, for: screenID)
XCTAssertFalse(screen.suppressHoverOpenUntilHoverExit)
}
func testOpeningSharedWorkspaceOnAnotherScreenClosesPreviousPresenter() {
let workspaceID = UUID()
let firstScreen = makeScreenContext(screenID: "screen-a", workspaceID: workspaceID)
let secondScreen = makeScreenContext(screenID: "screen-b", workspaceID: workspaceID)
let registry = TestScreenRegistry(activeScreenID: "screen-b", screens: [firstScreen, secondScreen])
let host = TestNotchPresentationHost()
let orchestrator = NotchOrchestrator(
screenRegistry: registry,
host: host,
settingsController: makeSettingsController(),
scheduler: TestScheduler()
)
orchestrator.open(screenID: "screen-a")
orchestrator.open(screenID: "screen-b")
XCTAssertEqual(firstScreen.notchState, .closed)
XCTAssertEqual(secondScreen.notchState, .open)
XCTAssertEqual(host.closedScreenIDs, ["screen-a"])
XCTAssertEqual(registry.presentingScreenID(for: workspaceID), "screen-b")
}
func testOpeningDifferentWorkspaceDoesNotCloseOtherOpenScreen() {
let firstScreen = makeScreenContext(screenID: "screen-a", workspaceID: UUID())
let secondScreen = makeScreenContext(screenID: "screen-b", workspaceID: UUID())
let registry = TestScreenRegistry(activeScreenID: "screen-b", screens: [firstScreen, secondScreen])
let host = TestNotchPresentationHost()
let orchestrator = NotchOrchestrator(
screenRegistry: registry,
host: host,
settingsController: makeSettingsController(),
scheduler: TestScheduler()
)
orchestrator.open(screenID: "screen-a")
orchestrator.open(screenID: "screen-b")
XCTAssertEqual(firstScreen.notchState, .open)
XCTAssertEqual(secondScreen.notchState, .open)
XCTAssertTrue(host.closedScreenIDs.isEmpty)
XCTAssertEqual(registry.presentingScreenID(for: firstScreen.workspaceID), "screen-a")
XCTAssertEqual(registry.presentingScreenID(for: secondScreen.workspaceID), "screen-b")
}
private func makeScreenContext(screenID: ScreenID, workspaceID: WorkspaceID = UUID()) -> ScreenContext {
ScreenContext(
id: screenID,
workspaceID: workspaceID,
settingsController: makeSettingsController(),
screenProvider: { _ in nil }
)
}
private func makeSettingsController() -> AppSettingsController {
let store = TestOrchestratorSettingsStore()
var settings = AppSettings.default
settings.behavior.openNotchOnHover = true
settings.behavior.minimumHoverDuration = 0.3
store.storedSettings = settings
return AppSettingsController(store: store)
}
}
@MainActor
private final class TestScreenRegistry: ScreenRegistryType {
private let activeID: ScreenID
private var screensByID: [ScreenID: ScreenContext]
private var workspacePresenters: [WorkspaceID: ScreenID] = [:]
init(activeScreenID: ScreenID, screens: [ScreenContext]) {
self.activeID = activeScreenID
self.screensByID = Dictionary(uniqueKeysWithValues: screens.map { ($0.id, $0) })
}
func allScreens() -> [ScreenContext] {
Array(screensByID.values)
}
func screenContext(for id: ScreenID) -> ScreenContext? {
screensByID[id]
}
func activeScreenID() -> ScreenID? {
activeID
}
func presentingScreenID(for workspaceID: WorkspaceID) -> ScreenID? {
workspacePresenters[workspaceID]
}
func claimWorkspacePresentation(for screenID: ScreenID) -> ScreenID? {
guard let workspaceID = screensByID[screenID]?.workspaceID else { return nil }
let previous = workspacePresenters[workspaceID]
workspacePresenters[workspaceID] = screenID
return previous == screenID ? nil : previous
}
func releaseWorkspacePresentation(for screenID: ScreenID) {
workspacePresenters = workspacePresenters.filter { $0.value != screenID }
}
}
@MainActor
private final class TestNotchPresentationHost: NotchPresentationHost {
var openedScreenIDs: [ScreenID] = []
var closedScreenIDs: [ScreenID] = []
func canPresentNotch(for screenID: ScreenID) -> Bool {
true
}
func performOpenPresentation(for screenID: ScreenID) {
openedScreenIDs.append(screenID)
}
func performClosePresentation(for screenID: ScreenID) {
closedScreenIDs.append(screenID)
}
}
private final class TestScheduler: SchedulerType {
private final class ScheduledAction {
let action: @MainActor () -> Void
var isCancelled = false
init(action: @escaping @MainActor () -> Void) {
self.action = action
}
}
private var scheduledActions: [ScheduledAction] = []
@MainActor
func schedule(after interval: TimeInterval, action: @escaping @MainActor () -> Void) -> AnyCancellable {
let scheduledAction = ScheduledAction(action: action)
scheduledActions.append(scheduledAction)
return AnyCancellable {
scheduledAction.isCancelled = true
}
}
@MainActor
func runScheduledActions() {
let actions = scheduledActions
scheduledActions.removeAll()
for scheduledAction in actions where !scheduledAction.isCancelled {
scheduledAction.action()
}
}
}
private final class TestOrchestratorSettingsStore: AppSettingsStoreType {
var storedSettings = AppSettings.default
func load() -> AppSettings {
storedSettings
}
func save(_ settings: AppSettings) {
storedSettings = settings
}
}

View File

@@ -0,0 +1,67 @@
import XCTest
@testable import CommandNotch
@MainActor
final class ScreenContextTests: XCTestCase {
func testInteractiveResizeDefersPersistingSettingsUntilResizeEnds() {
let store = ScreenContextTestSettingsStore()
var settings = AppSettings.default
settings.display.openWidth = 640
settings.display.openHeight = 350
store.storedSettings = settings
let controller = AppSettingsController(store: store)
let screen = ScreenContext(
id: "screen-a",
workspaceID: UUID(),
settingsController: controller,
screenProvider: { _ in nil }
)
screen.open()
screen.beginInteractiveResize()
screen.resizeOpenNotch(to: CGSize(width: 800, height: 420))
XCTAssertEqual(screen.notchSize.width, 800)
XCTAssertEqual(screen.notchSize.height, 420)
XCTAssertEqual(controller.settings.display.openWidth, 640)
XCTAssertEqual(controller.settings.display.openHeight, 350)
screen.endInteractiveResize()
XCTAssertEqual(controller.settings.display.openWidth, 800)
XCTAssertEqual(controller.settings.display.openHeight, 420)
XCTAssertEqual(store.storedSettings.display.openWidth, 800)
XCTAssertEqual(store.storedSettings.display.openHeight, 420)
}
func testFocusLossAutoCloseSuppressionCanBeToggled() {
let controller = AppSettingsController(store: ScreenContextTestSettingsStore())
let screen = ScreenContext(
id: "screen-a",
workspaceID: UUID(),
settingsController: controller,
screenProvider: { _ in nil }
)
XCTAssertFalse(screen.suppressCloseOnFocusLoss)
screen.setCloseOnFocusLossSuppressed(true)
XCTAssertTrue(screen.suppressCloseOnFocusLoss)
screen.setCloseOnFocusLossSuppressed(false)
XCTAssertFalse(screen.suppressCloseOnFocusLoss)
}
}
private final class ScreenContextTestSettingsStore: AppSettingsStoreType {
var storedSettings = AppSettings.default
func load() -> AppSettings {
storedSettings
}
func save(_ settings: AppSettings) {
storedSettings = settings
}
}

View File

@@ -0,0 +1,319 @@
import XCTest
@testable import CommandNotch
@MainActor
final class ScreenRegistryTests: XCTestCase {
func testRefreshCreatesContextsForConnectedScreensUsingDefaultWorkspace() {
let workspaceRegistry = makeWorkspaceRegistry()
let settingsController = makeSettingsController()
let registry = ScreenRegistry(
workspaceRegistry: workspaceRegistry,
settingsController: settingsController,
connectedScreenIDsProvider: { ["screen-a", "screen-b"] },
activeScreenIDProvider: { "screen-b" },
screenLookup: { _ in nil }
)
XCTAssertEqual(registry.allScreens().map(\.id), ["screen-a", "screen-b"])
XCTAssertEqual(
registry.allScreens().map(\.workspaceID),
[workspaceRegistry.defaultWorkspaceID, workspaceRegistry.defaultWorkspaceID]
)
XCTAssertEqual(registry.activeScreenID(), "screen-b")
}
func testAssignWorkspaceUpdatesContextAndSurvivesReconnect() {
let workspaceRegistry = makeWorkspaceRegistry()
let settingsController = makeSettingsController()
var connectedScreenIDs = ["screen-a"]
let registry = ScreenRegistry(
workspaceRegistry: workspaceRegistry,
settingsController: settingsController,
connectedScreenIDsProvider: { connectedScreenIDs },
activeScreenIDProvider: { connectedScreenIDs.first },
screenLookup: { _ in nil }
)
let docsWorkspaceID = workspaceRegistry.createWorkspace(named: "Docs")
registry.assignWorkspace(docsWorkspaceID, to: "screen-a")
XCTAssertEqual(registry.screenContext(for: "screen-a")?.workspaceID, docsWorkspaceID)
connectedScreenIDs = []
registry.refreshConnectedScreens()
XCTAssertNil(registry.screenContext(for: "screen-a"))
connectedScreenIDs = ["screen-a"]
registry.refreshConnectedScreens()
XCTAssertEqual(registry.screenContext(for: "screen-a")?.workspaceID, docsWorkspaceID)
}
func testDeletedWorkspaceAssignmentFallsBackToDefaultWorkspace() {
let workspaceRegistry = makeWorkspaceRegistry()
let settingsController = makeSettingsController()
let assignmentStore = InMemoryScreenAssignmentStore()
let registry = ScreenRegistry(
workspaceRegistry: workspaceRegistry,
settingsController: settingsController,
assignmentStore: assignmentStore,
connectedScreenIDsProvider: { ["screen-a"] },
activeScreenIDProvider: { "screen-a" },
screenLookup: { _ in nil }
)
let reviewWorkspaceID = workspaceRegistry.createWorkspace(named: "Review")
registry.assignWorkspace(reviewWorkspaceID, to: "screen-a")
workspaceRegistry.deleteWorkspace(id: reviewWorkspaceID)
registry.refreshConnectedScreens()
XCTAssertEqual(
registry.screenContext(for: "screen-a")?.workspaceID,
workspaceRegistry.defaultWorkspaceID
)
XCTAssertEqual(
assignmentStore.savedAssignments["screen-a"],
workspaceRegistry.defaultWorkspaceID
)
}
func testRegistryLoadsPersistedAssignmentsFromStore() {
let workspaceRegistry = makeWorkspaceRegistry()
let settingsController = makeSettingsController()
let docsWorkspaceID = workspaceRegistry.createWorkspace(named: "Docs")
let assignmentStore = InMemoryScreenAssignmentStore()
assignmentStore.savedAssignments = ["screen-a": docsWorkspaceID]
let registry = ScreenRegistry(
workspaceRegistry: workspaceRegistry,
settingsController: settingsController,
assignmentStore: assignmentStore,
connectedScreenIDsProvider: { ["screen-a"] },
activeScreenIDProvider: { "screen-a" },
screenLookup: { _ in nil }
)
XCTAssertEqual(registry.screenContext(for: "screen-a")?.workspaceID, docsWorkspaceID)
}
func testAssignWorkspacePersistsAssignments() {
let workspaceRegistry = makeWorkspaceRegistry()
let settingsController = makeSettingsController()
let assignmentStore = InMemoryScreenAssignmentStore()
let registry = ScreenRegistry(
workspaceRegistry: workspaceRegistry,
settingsController: settingsController,
assignmentStore: assignmentStore,
connectedScreenIDsProvider: { ["screen-a"] },
activeScreenIDProvider: { "screen-a" },
screenLookup: { _ in nil }
)
let reviewWorkspaceID = workspaceRegistry.createWorkspace(named: "Review")
registry.assignWorkspace(reviewWorkspaceID, to: "screen-a")
XCTAssertEqual(assignmentStore.savedAssignments["screen-a"], reviewWorkspaceID)
}
func testWorkspaceControllerTracksAssignedWorkspace() {
let workspaceRegistry = makeWorkspaceRegistry()
let settingsController = makeSettingsController()
let registry = ScreenRegistry(
workspaceRegistry: workspaceRegistry,
settingsController: settingsController,
connectedScreenIDsProvider: { ["screen-a"] },
activeScreenIDProvider: { "screen-a" },
screenLookup: { _ in nil }
)
let docsWorkspaceID = workspaceRegistry.createWorkspace(named: "Docs")
XCTAssertEqual(registry.workspaceController(for: "screen-a").id, workspaceRegistry.defaultWorkspaceID)
registry.assignWorkspace(docsWorkspaceID, to: "screen-a")
XCTAssertEqual(registry.workspaceController(for: "screen-a").id, docsWorkspaceID)
}
func testDeleteWorkspaceReassignsConnectedAndPersistedScreensToFallback() {
let workspaceRegistry = makeWorkspaceRegistry()
let settingsController = makeSettingsController()
let docsWorkspaceID = workspaceRegistry.createWorkspace(named: "Docs")
let reviewWorkspaceID = workspaceRegistry.createWorkspace(named: "Review")
let assignmentStore = InMemoryScreenAssignmentStore()
assignmentStore.savedAssignments = ["screen-a": docsWorkspaceID, "screen-b": docsWorkspaceID]
let registry = ScreenRegistry(
workspaceRegistry: workspaceRegistry,
settingsController: settingsController,
assignmentStore: assignmentStore,
connectedScreenIDsProvider: { ["screen-a"] },
activeScreenIDProvider: { "screen-a" },
screenLookup: { _ in nil }
)
registry.assignWorkspace(docsWorkspaceID, to: "screen-a")
let fallbackWorkspaceID = registry.deleteWorkspace(
docsWorkspaceID,
preferredFallback: reviewWorkspaceID
)
XCTAssertEqual(fallbackWorkspaceID, reviewWorkspaceID)
XCTAssertEqual(registry.screenContext(for: "screen-a")?.workspaceID, reviewWorkspaceID)
XCTAssertEqual(assignmentStore.savedAssignments["screen-a"], reviewWorkspaceID)
XCTAssertEqual(assignmentStore.savedAssignments["screen-b"], reviewWorkspaceID)
}
func testAssignedScreenCountIncludesDisconnectedAssignments() {
let workspaceRegistry = makeWorkspaceRegistry()
let settingsController = makeSettingsController()
let docsWorkspaceID = workspaceRegistry.createWorkspace(named: "Docs")
let assignmentStore = InMemoryScreenAssignmentStore()
assignmentStore.savedAssignments = ["screen-a": docsWorkspaceID, "screen-b": docsWorkspaceID]
let registry = ScreenRegistry(
workspaceRegistry: workspaceRegistry,
settingsController: settingsController,
assignmentStore: assignmentStore,
connectedScreenIDsProvider: { ["screen-a"] },
activeScreenIDProvider: { "screen-a" },
screenLookup: { _ in nil }
)
XCTAssertEqual(registry.assignedScreenCount(to: docsWorkspaceID), 2)
XCTAssertEqual(registry.assignedScreenIDs(to: docsWorkspaceID), ["screen-a", "screen-b"])
}
func testClaimWorkspacePresentationTracksPresenterPerWorkspace() {
let workspaceRegistry = makeWorkspaceRegistry()
let settingsController = makeSettingsController()
let docsWorkspaceID = workspaceRegistry.createWorkspace(named: "Docs")
let registry = ScreenRegistry(
workspaceRegistry: workspaceRegistry,
settingsController: settingsController,
connectedScreenIDsProvider: { ["screen-a", "screen-b"] },
activeScreenIDProvider: { "screen-a" },
screenLookup: { _ in nil }
)
registry.assignWorkspace(docsWorkspaceID, to: "screen-a")
registry.assignWorkspace(docsWorkspaceID, to: "screen-b")
XCTAssertNil(registry.claimWorkspacePresentation(for: "screen-a"))
XCTAssertEqual(registry.presentingScreenID(for: docsWorkspaceID), "screen-a")
XCTAssertEqual(registry.claimWorkspacePresentation(for: "screen-b"), "screen-a")
XCTAssertEqual(registry.presentingScreenID(for: docsWorkspaceID), "screen-b")
registry.releaseWorkspacePresentation(for: "screen-b")
XCTAssertNil(registry.presentingScreenID(for: docsWorkspaceID))
}
func testAssignWorkspaceReleasesPreviousPresentationOwnership() {
let workspaceRegistry = makeWorkspaceRegistry()
let settingsController = makeSettingsController()
let docsWorkspaceID = workspaceRegistry.createWorkspace(named: "Docs")
let reviewWorkspaceID = workspaceRegistry.createWorkspace(named: "Review")
let registry = ScreenRegistry(
workspaceRegistry: workspaceRegistry,
settingsController: settingsController,
connectedScreenIDsProvider: { ["screen-a"] },
activeScreenIDProvider: { "screen-a" },
screenLookup: { _ in nil }
)
registry.assignWorkspace(docsWorkspaceID, to: "screen-a")
_ = registry.claimWorkspacePresentation(for: "screen-a")
XCTAssertEqual(registry.presentingScreenID(for: docsWorkspaceID), "screen-a")
registry.assignWorkspace(reviewWorkspaceID, to: "screen-a")
XCTAssertNil(registry.presentingScreenID(for: docsWorkspaceID))
XCTAssertEqual(registry.screenContext(for: "screen-a")?.workspaceID, reviewWorkspaceID)
}
func testConnectedScreenSummariesReflectActiveScreenAndAssignments() {
let workspaceRegistry = makeWorkspaceRegistry()
let settingsController = makeSettingsController()
let docsWorkspaceID = workspaceRegistry.createWorkspace(named: "Docs")
let registry = ScreenRegistry(
workspaceRegistry: workspaceRegistry,
settingsController: settingsController,
connectedScreenIDsProvider: { ["screen-a", "screen-b"] },
activeScreenIDProvider: { "screen-b" },
screenLookup: { _ in nil }
)
registry.assignWorkspace(docsWorkspaceID, to: "screen-a")
XCTAssertEqual(
registry.connectedScreenSummaries(),
[
ConnectedScreenSummary(
id: "screen-a",
displayName: "Screen 1",
isActive: false,
assignedWorkspaceID: docsWorkspaceID
),
ConnectedScreenSummary(
id: "screen-b",
displayName: "Screen 2",
isActive: true,
assignedWorkspaceID: workspaceRegistry.defaultWorkspaceID
)
]
)
}
private func makeWorkspaceRegistry() -> WorkspaceRegistry {
let settingsProvider = ScreenRegistryTestSettingsProvider()
let sessionFactory = ScreenRegistryUnusedTerminalSessionFactory()
return WorkspaceRegistry(
initialWorkspaces: [],
controllerFactory: { summary in
WorkspaceController(
summary: summary,
sessionFactory: sessionFactory,
settingsProvider: settingsProvider,
bootstrapDefaultTab: false
)
}
)
}
private func makeSettingsController() -> AppSettingsController {
AppSettingsController(store: TestAppSettingsStore())
}
}
private final class InMemoryScreenAssignmentStore: ScreenAssignmentStoreType {
var savedAssignments: [ScreenID: WorkspaceID] = [:]
func loadScreenAssignments() -> [ScreenID: WorkspaceID] {
savedAssignments
}
func saveScreenAssignments(_ assignments: [ScreenID: WorkspaceID]) {
savedAssignments = assignments
}
}
private final class TestAppSettingsStore: AppSettingsStoreType {
private var storedSettings = AppSettings.default
func load() -> AppSettings {
storedSettings
}
func save(_ settings: AppSettings) {
storedSettings = settings
}
}
private final class ScreenRegistryTestSettingsProvider: TerminalSessionConfigurationProviding {
let terminalSessionConfiguration = TerminalSessionConfiguration(fontSize: 13, theme: .terminalApp, shellPath: "")
let hotkeySettings = AppSettings.default.hotkeys
let terminalSizePresets = TerminalSizePresetStore.loadDefaults()
}
private struct ScreenRegistryUnusedTerminalSessionFactory: TerminalSessionFactoryType {
@MainActor
func makeSession(fontSize: CGFloat, theme: TerminalTheme, shellPath: String) -> TerminalSession {
fatalError("ScreenRegistryTests should not create live terminal sessions.")
}
}

View File

@@ -0,0 +1,57 @@
import AppKit
import Carbon.HIToolbox
import XCTest
import SwiftTerm
@testable import CommandNotch
final class TerminalCommandArrowBehaviorTests: XCTestCase {
func testCommandLeftUsesHomeSequence() {
let sequence = TerminalCommandArrowBehavior.sequence(
for: [.command],
keyCode: UInt16(kVK_LeftArrow),
applicationCursor: false
)
XCTAssertEqual(sequence, EscapeSequences.moveHomeNormal)
}
func testCommandRightUsesApplicationEndSequence() {
let sequence = TerminalCommandArrowBehavior.sequence(
for: [.command],
keyCode: UInt16(kVK_RightArrow),
applicationCursor: true
)
XCTAssertEqual(sequence, EscapeSequences.moveEndApp)
}
func testOptionLeftKeepsSwiftTermWordNavigationPath() {
let sequence = TerminalCommandArrowBehavior.sequence(
for: [.option],
keyCode: UInt16(kVK_LeftArrow),
applicationCursor: false
)
XCTAssertNil(sequence)
}
func testCommandDeleteUsesLineKillSequence() {
let sequence = TerminalCommandArrowBehavior.sequence(
for: [.command],
keyCode: UInt16(kVK_Delete),
applicationCursor: false
)
XCTAssertEqual(sequence, [0x15])
}
func testCommandLUsesClearScreenSequence() {
let sequence = TerminalCommandArrowBehavior.sequence(
for: [.command],
keyCode: UInt16(kVK_ANSI_L),
applicationCursor: false
)
XCTAssertEqual(sequence, [0x0c])
}
}

View File

@@ -0,0 +1,32 @@
import XCTest
@testable import CommandNotch
final class WindowFrameCalculatorTests: XCTestCase {
func testClosedStateCentersWindowOnScreen() {
let frame = WindowFrameCalculator.targetFrame(
screenFrame: CGRect(x: 100, y: 50, width: 1600, height: 900),
currentWindowFrame: CGRect(x: 300, y: 0, width: 0, height: 0),
notchState: .closed,
contentSize: CGSize(width: 800, height: 300),
centerHorizontally: false
)
XCTAssertEqual(frame.origin.x, 480, accuracy: 0.001)
XCTAssertEqual(frame.origin.y, 630, accuracy: 0.001)
XCTAssertEqual(frame.size.width, 840, accuracy: 0.001)
XCTAssertEqual(frame.size.height, 320, accuracy: 0.001)
}
func testOpenStateClampsDraggedFrameWithinScreenBounds() {
let frame = WindowFrameCalculator.targetFrame(
screenFrame: CGRect(x: 0, y: 0, width: 1440, height: 900),
currentWindowFrame: CGRect(x: 1200, y: 0, width: 0, height: 0),
notchState: .open,
contentSize: CGSize(width: 900, height: 320),
centerHorizontally: false
)
XCTAssertEqual(frame.origin.x, 500, accuracy: 0.001)
XCTAssertEqual(frame.origin.y, 560, accuracy: 0.001)
}
}

View File

@@ -0,0 +1,146 @@
import XCTest
@testable import CommandNotch
@MainActor
final class WorkspaceRegistryTests: XCTestCase {
func testRegistryCreatesDefaultWorkspaceWhenEmpty() {
let registry = makeRegistry()
XCTAssertEqual(registry.allWorkspaceSummaries().count, 1)
XCTAssertEqual(registry.allWorkspaceSummaries().first?.name, "Main")
XCTAssertNotNil(registry.controller(for: registry.defaultWorkspaceID))
}
func testCreateRenameAndDeleteWorkspaceUpdatesSummaries() {
let registry = makeRegistry()
let createdID = registry.createWorkspace(named: "Docs")
XCTAssertEqual(registry.allWorkspaceSummaries().map(\.name), ["Main", "Docs"])
registry.renameWorkspace(id: createdID, to: "Review")
XCTAssertEqual(registry.allWorkspaceSummaries().map(\.name), ["Main", "Review"])
registry.deleteWorkspace(id: createdID)
XCTAssertEqual(registry.allWorkspaceSummaries().map(\.name), ["Main"])
}
func testDeletingLastWorkspaceIsIgnored() {
let registry = makeRegistry()
let onlyWorkspaceID = registry.defaultWorkspaceID
registry.deleteWorkspace(id: onlyWorkspaceID)
XCTAssertEqual(registry.allWorkspaceSummaries().count, 1)
XCTAssertEqual(registry.allWorkspaceSummaries().first?.id, onlyWorkspaceID)
}
func testDeletionFallbackPrefersRequestedWorkspaceWhenAvailable() {
let registry = makeRegistry()
let docsID = registry.createWorkspace(named: "Docs")
let reviewID = registry.createWorkspace(named: "Review")
let fallback = registry.deletionFallbackWorkspaceID(
forDeleting: docsID,
preferredFallback: reviewID
)
XCTAssertEqual(fallback, reviewID)
}
func testRegistryLoadsPersistedWorkspacesFromStore() {
let store = InMemoryWorkspaceStore()
let docsID = UUID(uuidString: "11111111-1111-1111-1111-111111111111")!
store.savedSummaries = [WorkspaceSummary(id: docsID, name: "Docs")]
let registry = makeRegistry(initialWorkspaces: nil, store: store)
XCTAssertEqual(registry.allWorkspaceSummaries().count, 1)
XCTAssertEqual(registry.allWorkspaceSummaries().first?.id, docsID)
XCTAssertEqual(registry.allWorkspaceSummaries().first?.name, "Docs")
XCTAssertEqual(registry.defaultWorkspaceID, docsID)
}
func testRegistryPersistsWorkspaceSummaryChanges() {
let store = InMemoryWorkspaceStore()
let registry = makeRegistry(store: store)
let createdID = registry.createWorkspace(named: "Docs")
XCTAssertEqual(store.savedSummaries.map(\.name), ["Main", "Docs"])
registry.renameWorkspace(id: createdID, to: "Review")
XCTAssertEqual(store.savedSummaries.map(\.name), ["Main", "Review"])
registry.deleteWorkspace(id: createdID)
XCTAssertEqual(store.savedSummaries.map(\.name), ["Main"])
}
func testUpdateWorkspaceHotkeyPersistsAndUpdatesSummary() {
let store = InMemoryWorkspaceStore()
let registry = makeRegistry(store: store)
let docsID = registry.createWorkspace(named: "Docs")
let hotkey = HotkeyBinding.cmdShiftDigit(4)
registry.updateWorkspaceHotkey(id: docsID, to: hotkey)
XCTAssertEqual(registry.summary(for: docsID)?.hotkey, hotkey)
XCTAssertEqual(store.savedSummaries.last?.hotkey, hotkey)
}
func testNextAndPreviousWorkspaceWrapAroundRegistryOrder() {
let registry = makeRegistry()
let mainID = registry.defaultWorkspaceID
let docsID = registry.createWorkspace(named: "Docs")
let reviewID = registry.createWorkspace(named: "Review")
XCTAssertEqual(registry.nextWorkspaceID(after: mainID), docsID)
XCTAssertEqual(registry.nextWorkspaceID(after: reviewID), mainID)
XCTAssertEqual(registry.previousWorkspaceID(before: mainID), reviewID)
XCTAssertEqual(registry.previousWorkspaceID(before: docsID), mainID)
}
private func makeRegistry(
initialWorkspaces: [WorkspaceSummary]? = [],
store: (any WorkspaceStoreType)? = nil
) -> WorkspaceRegistry {
let settingsProvider = TestSettingsProvider()
let sessionFactory = UnusedTerminalSessionFactory()
return WorkspaceRegistry(
initialWorkspaces: initialWorkspaces,
store: store,
controllerFactory: { summary in
WorkspaceController(
summary: summary,
sessionFactory: sessionFactory,
settingsProvider: settingsProvider,
bootstrapDefaultTab: false
)
}
)
}
}
private final class InMemoryWorkspaceStore: WorkspaceStoreType {
var savedSummaries: [WorkspaceSummary] = []
func loadWorkspaceSummaries() -> [WorkspaceSummary] {
savedSummaries
}
func saveWorkspaceSummaries(_ summaries: [WorkspaceSummary]) {
savedSummaries = summaries
}
}
private final class TestSettingsProvider: TerminalSessionConfigurationProviding {
let terminalSessionConfiguration = TerminalSessionConfiguration(fontSize: 13, theme: .terminalApp, shellPath: "")
let hotkeySettings = AppSettings.default.hotkeys
let terminalSizePresets = TerminalSizePresetStore.loadDefaults()
}
private struct UnusedTerminalSessionFactory: TerminalSessionFactoryType {
@MainActor
func makeSession(fontSize: CGFloat, theme: TerminalTheme, shellPath: String) -> TerminalSession {
fatalError("WorkspaceRegistryTests should not create live terminal sessions.")
}
}

View File

@@ -0,0 +1,40 @@
import XCTest
@testable import CommandNotch
final class WorkspaceStoreTests: XCTestCase {
func testWorkspaceStoreRoundTripsSummaries() {
let defaults = UserDefaults(suiteName: #function)!
defaults.removePersistentDomain(forName: #function)
defer { defaults.removePersistentDomain(forName: #function) }
let store = UserDefaultsWorkspaceStore(defaults: defaults)
let summaries = [
WorkspaceSummary(
id: UUID(uuidString: "11111111-1111-1111-1111-111111111111")!,
name: "Main",
hotkey: HotkeyBinding.cmdShiftDigit(4)
),
WorkspaceSummary(id: UUID(uuidString: "22222222-2222-2222-2222-222222222222")!, name: "Docs")
]
store.saveWorkspaceSummaries(summaries)
XCTAssertEqual(store.loadWorkspaceSummaries(), summaries)
}
func testScreenAssignmentStoreRoundTripsAssignments() {
let defaults = UserDefaults(suiteName: #function)!
defaults.removePersistentDomain(forName: #function)
defer { defaults.removePersistentDomain(forName: #function) }
let store = UserDefaultsScreenAssignmentStore(defaults: defaults)
let assignments: [ScreenID: WorkspaceID] = [
"screen-a": UUID(uuidString: "11111111-1111-1111-1111-111111111111")!,
"screen-b": UUID(uuidString: "22222222-2222-2222-2222-222222222222")!
]
store.saveScreenAssignments(assignments)
XCTAssertEqual(store.loadScreenAssignments(), assignments)
}
}

View File

@@ -0,0 +1,103 @@
import XCTest
final class CommandNotchUITests: XCTestCase {
override func setUpWithError() throws {
continueAfterFailure = false
}
func testSettingsWorkspaceRenameFlow() {
let app = launchIntoSettings()
let newWorkspaceButton = app.buttons["settings.workspaces.new"]
XCTAssertTrue(newWorkspaceButton.waitForExistence(timeout: 5))
newWorkspaceButton.tap()
let nameField = app.textFields["settings.workspaces.name-field"]
XCTAssertTrue(nameField.waitForExistence(timeout: 5))
nameField.tap()
nameField.typeKey("a", modifierFlags: .command)
nameField.typeText(XCUIKeyboardKey.delete.rawValue)
nameField.typeText("Docs")
let saveButton = app.buttons["settings.workspaces.save-name"]
XCTAssertTrue(saveButton.exists)
saveButton.tap()
XCTAssertTrue(app.staticTexts["Docs"].waitForExistence(timeout: 5))
}
func testSettingsWorkspaceDeleteFallsBackToMain() {
let app = launchIntoSettings()
let newWorkspaceButton = app.buttons["settings.workspaces.new"]
XCTAssertTrue(newWorkspaceButton.waitForExistence(timeout: 5))
newWorkspaceButton.tap()
let nameField = app.textFields["settings.workspaces.name-field"]
XCTAssertTrue(nameField.waitForExistence(timeout: 5))
nameField.tap()
nameField.typeKey("a", modifierFlags: .command)
nameField.typeText(XCUIKeyboardKey.delete.rawValue)
nameField.typeText("Scratch")
let saveButton = app.buttons["settings.workspaces.save-name"]
XCTAssertTrue(saveButton.exists)
saveButton.tap()
XCTAssertTrue(app.staticTexts["Scratch"].waitForExistence(timeout: 5))
let assignCurrentButton = app.buttons["settings.workspaces.assign-current"]
XCTAssertTrue(assignCurrentButton.waitForExistence(timeout: 5))
assignCurrentButton.tap()
let deleteButton = app.buttons["settings.workspaces.delete"]
XCTAssertTrue(deleteButton.waitForExistence(timeout: 5))
deleteButton.tap()
let confirmDeleteButton = app.sheets.buttons["Delete"]
XCTAssertTrue(confirmDeleteButton.waitForExistence(timeout: 5))
confirmDeleteButton.tap()
let mainValuePredicate = NSPredicate(format: "value == %@", "Main")
expectation(for: mainValuePredicate, evaluatedWith: nameField)
waitForExpectations(timeout: 5)
}
func testOpenNotchLaunchShowsInteractiveControls() {
let app = XCUIApplication()
app.launchArguments = [
"--uitest-regular-activation",
"--uitest-open-notch"
]
app.launch()
let notch = app.descendants(matching: .any)["notch.container"]
XCTAssertTrue(notch.waitForExistence(timeout: 5))
let newTabButton = app.buttons["New Tab"]
XCTAssertTrue(newTabButton.waitForExistence(timeout: 5))
let settingsButton = app.buttons["Settings"]
XCTAssertTrue(settingsButton.waitForExistence(timeout: 5))
settingsButton.tap()
XCTAssertTrue(app.windows["CommandNotch Settings"].waitForExistence(timeout: 5))
}
@discardableResult
private func launchIntoSettings() -> XCUIApplication {
let app = XCUIApplication()
app.launchArguments = [
"--uitest-regular-activation",
"--uitest-show-settings"
]
app.launch()
let settingsWindow = app.windows["CommandNotch Settings"]
XCTAssertTrue(settingsWindow.waitForExistence(timeout: 5))
let workspacesTab = app.descendants(matching: .any)["settings.tab.workspaces"]
XCTAssertTrue(workspacesTab.waitForExistence(timeout: 5))
workspacesTab.tap()
return app
}
}

115
CommandNotch/project.yml Normal file
View File

@@ -0,0 +1,115 @@
name: CommandNotch
options:
bundleIdPrefix: com.commandnotch
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"
schemes:
CommandNotch:
build:
targets:
CommandNotch: all
test:
config: Debug
targets:
- CommandNotchTests
- CommandNotchUITests
run:
config: Debug
profile:
config: Release
analyze:
config: Debug
archive:
config: Release
management:
shared: true
Release-CommandNotch:
build:
targets:
CommandNotch: all
test:
config: Debug
targets:
- CommandNotchTests
- CommandNotchUITests
run:
config: Release
profile:
config: Release
analyze:
config: Debug
archive:
config: Release
management:
shared: true
targets:
CommandNotch:
type: application
platform: macOS
sources:
- path: CommandNotch
excludes:
- Resources/Info.plist
dependencies:
- package: SwiftTerm
info:
path: CommandNotch/Resources/Info.plist
properties:
CFBundleName: CommandNotch
CFBundleDisplayName: CommandNotch
CFBundleIdentifier: "$(PRODUCT_BUNDLE_IDENTIFIER)"
CFBundleVersion: "1"
CFBundleShortVersionString: "0.0.3"
CFBundlePackageType: APPL
CFBundleExecutable: CommandNotch
LSMinimumSystemVersion: "14.0"
LSUIElement: true
NSHumanReadableCopyright: "Copyright © 2026 CommandNotch. All rights reserved."
entitlements:
path: CommandNotch/Resources/CommandNotch.entitlements
settings:
base:
CODE_SIGN_ENTITLEMENTS: CommandNotch/Resources/CommandNotch.entitlements
INFOPLIST_FILE: CommandNotch/Resources/Info.plist
PRODUCT_BUNDLE_IDENTIFIER: com.commandnotch.app
PRODUCT_NAME: CommandNotch
COMBINE_HIDPI_IMAGES: true
CommandNotchTests:
type: bundle.unit-test
platform: macOS
sources:
- path: CommandNotchTests
dependencies:
- target: CommandNotch
settings:
base:
GENERATE_INFOPLIST_FILE: true
PRODUCT_BUNDLE_IDENTIFIER: com.commandnotch.CommandNotchTests
PRODUCT_NAME: CommandNotchTests
TEST_HOST: "$(BUILT_PRODUCTS_DIR)/CommandNotch.app/Contents/MacOS/CommandNotch"
BUNDLE_LOADER: "$(TEST_HOST)"
CommandNotchUITests:
type: bundle.ui-testing
platform: macOS
sources:
- path: CommandNotchUITests
dependencies:
- target: CommandNotch
settings:
base:
DEVELOPMENT_TEAM: G698BP272N
GENERATE_INFOPLIST_FILE: true
PRODUCT_BUNDLE_IDENTIFIER: com.commandnotch.CommandNotchUITests
PRODUCT_NAME: CommandNotchUITests
TEST_TARGET_NAME: CommandNotch

View File

@@ -1,478 +0,0 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 63;
objects = {
/* Begin PBXBuildFile section */
0F4A88A33D93B6E100A1C001 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0F4A88A33D93B6E100A1C002 /* Assets.xcassets */; };
2213F430F3D8A88033607CD2 /* NotchSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA6359CF9DDF89413440300D /* NotchSettings.swift */; };
247C6F84E7ADE7AED43381E2 /* CommandNotchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B671125208055E5334CB85E /* CommandNotchApp.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 */; };
3A1F0C4BE9D84A5C8E2B7101 /* TerminalTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A1F0C4AE9D84A5C8E2B7101 /* TerminalTheme.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 */; };
EA604F3F38D6638C7236CDC2 /* LaunchAtLoginHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B567F3B5D006D2B35630CFF /* LaunchAtLoginHelper.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>"; };
0B567F3B5D006D2B35630CFF /* LaunchAtLoginHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchAtLoginHelper.swift; sourceTree = "<group>"; };
0F4A88A33D93B6E100A1C002 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; 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 /* CommandNotchApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandNotchApp.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 /* CommandNotch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CommandNotch.app; sourceTree = BUILT_PRODUCTS_DIR; };
7B598809B19C892470DE7268 /* TerminalSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSession.swift; sourceTree = "<group>"; };
3A1F0C4AE9D84A5C8E2B7101 /* TerminalTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalTheme.swift; sourceTree = "<group>"; };
9547A79F60E46F4521A70674 /* CommandNotch.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = CommandNotch.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 = (
0F4A88A33D93B6E100A1C002 /* Assets.xcassets */,
9547A79F60E46F4521A70674 /* CommandNotch.entitlements */,
);
path = Resources;
sourceTree = "<group>";
};
27C90448ECAC906F0DA429C0 /* Managers */ = {
isa = PBXGroup;
children = (
3B72743F178231E0B06DD3DE /* HotkeyManager.swift */,
0B567F3B5D006D2B35630CFF /* LaunchAtLoginHelper.swift */,
EA1CD1DE020F0467AFB98DE3 /* PopoutWindowController.swift */,
15A290D4D21D6C01A583A372 /* ScreenManager.swift */,
0A973877BCE6084D0EBBBDBD /* SettingsWindowController.swift */,
);
path = Managers;
sourceTree = "<group>";
};
792DD4F8C079680683D8FF7A /* Products */ = {
isa = PBXGroup;
children = (
665CFC051CF185B71199608D /* CommandNotch.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 */,
3A1F0C4AE9D84A5C8E2B7101 /* TerminalTheme.swift */,
);
path = Models;
sourceTree = "<group>";
};
8D95E0324E6AFC9E4DC0C087 /* Extensions */ = {
isa = PBXGroup;
children = (
F0CED6A0F25A6E57D8AA308A /* NSScreen+Extensions.swift */,
);
path = Extensions;
sourceTree = "<group>";
};
9E1CA4816F67033BBD52D8A3 /* CommandNotch */ = {
isa = PBXGroup;
children = (
5C0779490DE9020FBBC464BE /* AppDelegate.swift */,
20BA7F4716DA3909DA8BC381 /* ContentView.swift */,
4B671125208055E5334CB85E /* CommandNotchApp.swift */,
F32F526005A2589010E63C76 /* Components */,
8D95E0324E6AFC9E4DC0C087 /* Extensions */,
27C90448ECAC906F0DA429C0 /* Managers */,
869AD33E1CDEB9CBAD401BA6 /* Models */,
0EF94ED46B4860C241540F0A /* Resources */,
C2B8955F4D0A1DAA7E60326A /* Views */,
);
path = CommandNotch;
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 /* CommandNotch */,
792DD4F8C079680683D8FF7A /* Products */,
);
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
1485207FA11756EC2DF4F08B /* CommandNotch */ = {
isa = PBXNativeTarget;
buildConfigurationList = 74CB98309F5464CDCB00C63A /* Build configuration list for PBXNativeTarget "CommandNotch" */;
buildPhases = (
F3C6D5CD1247D246A3F6F7AB /* Sources */,
6085DF2BDFFB2A99C4ABD514 /* Frameworks */,
0F4A88A33D93B6E100A1C003 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = CommandNotch;
packageProductDependencies = (
032AECA58EA4C274BE9F3320 /* SwiftTerm */,
);
productName = CommandNotch;
productReference = 665CFC051CF185B71199608D /* CommandNotch.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 "CommandNotch" */;
compatibilityVersion = "Xcode 14.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
Base,
en,
);
mainGroup = FC6F23514BFE2235BD4154E8;
minimizedProjectReferenceProxies = 1;
packageReferences = (
80F03B77566BF59C9941EAD4 /* XCRemoteSwiftPackageReference "SwiftTerm" */,
);
projectDirPath = "";
projectRoot = "";
targets = (
1485207FA11756EC2DF4F08B /* CommandNotch */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
0F4A88A33D93B6E100A1C003 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
0F4A88A33D93B6E100A1C001 /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
F3C6D5CD1247D246A3F6F7AB /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
81A912E3E16165D999882078 /* AppDelegate.swift in Sources */,
888C45C650327089EBD39B2E /* ContentView.swift in Sources */,
247C6F84E7ADE7AED43381E2 /* CommandNotchApp.swift in Sources */,
88EBFBB2292659EA7C42A8F9 /* HotkeyBinding.swift in Sources */,
4D5125E11B4DDBDB3DFACBAF /* HotkeyManager.swift in Sources */,
7BD705CA6A34117929B362EC /* HotkeyRecorderView.swift in Sources */,
EA604F3F38D6638C7236CDC2 /* LaunchAtLoginHelper.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 */,
3A1F0C4BE9D84A5C8E2B7101 /* TerminalTheme.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
0B8C784EF064E46C44076D6B /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CLANG_USE_OPTIMIZATION_PROFILE = YES;
CODE_SIGN_ENTITLEMENTS = CommandNotch/Resources/CommandNotch.entitlements;
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
COMBINE_HIDPI_IMAGES = YES;
"DEVELOPMENT_TEAM[sdk=macosx*]" = G698BP272N;
INFOPLIST_FILE = CommandNotch/Resources/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = CommandNotch;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 0.0.3;
PRODUCT_BUNDLE_IDENTIFIER = com.commandnotch.app;
PRODUCT_NAME = CommandNotch;
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;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CLANG_USE_OPTIMIZATION_PROFILE = YES;
CODE_SIGN_ENTITLEMENTS = CommandNotch/Resources/CommandNotch.entitlements;
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
COMBINE_HIDPI_IMAGES = YES;
"DEVELOPMENT_TEAM[sdk=macosx*]" = G698BP272N;
INFOPLIST_FILE = CommandNotch/Resources/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = CommandNotch;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 0.0.3;
PRODUCT_BUNDLE_IDENTIFIER = com.commandnotch.app;
PRODUCT_NAME = CommandNotch;
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 "CommandNotch" */ = {
isa = XCConfigurationList;
buildConfigurations = (
7020C02C1BDF63690CC9A3AC /* Debug */,
0B8C784EF064E46C44076D6B /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
D1C4019FEAFC83BB053C9E6E /* Build configuration list for PBXProject "CommandNotch" */ = {
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

@@ -1,88 +0,0 @@
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)
// Sync the launch-at-login toggle with the actual system state
// in case the user toggled it from System Settings.
UserDefaults.standard.set(LaunchAtLoginHelper.isEnabled, forKey: NotchSettings.Keys.launchAtLogin)
ScreenManager.shared.start()
observeDisplayPreference()
observeSizePreferences()
observeFontSizeChanges()
observeTerminalThemeChanges()
}
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)
}
/// Live-update terminal colors across all sessions.
private func observeTerminalThemeChanges() {
UserDefaults.standard.publisher(for: \.terminalTheme)
.removeDuplicates()
.sink { newTheme in
TerminalManager.shared.updateAllThemes(TerminalTheme.resolve(newTheme))
}
.store(in: &cancellables)
}
}
// MARK: - KVO key paths
private extension UserDefaults {
@objc var terminalFontSize: Double {
double(forKey: NotchSettings.Keys.terminalFontSize)
}
@objc var terminalTheme: String {
string(forKey: NotchSettings.Keys.terminalTheme) ?? NotchSettings.Defaults.terminalTheme
}
@objc var showOnAllDisplays: Bool {
bool(forKey: NotchSettings.Keys.showOnAllDisplays)
}
}

View File

@@ -1,404 +0,0 @@
import AppKit
import SwiftUI
import Combine
/// Manages one NotchWindow per connected display.
/// Routes all open/close through centralized methods that handle
/// window activation, key status, and first responder assignment
/// so the terminal can receive keyboard input.
@MainActor
class ScreenManager: ObservableObject {
static let shared = ScreenManager()
private let focusRetryDelay: TimeInterval = 0.01
private let presetResizeFrameInterval: TimeInterval = 1.0 / 60.0
private(set) var windows: [String: NotchWindow] = [:]
private(set) var viewModels: [String: NotchViewModel] = [:]
private var presetResizeTimers: [String: Timer] = [:]
@AppStorage(NotchSettings.Keys.showOnAllDisplays)
private var showOnAllDisplays = NotchSettings.Defaults.showOnAllDisplays
private 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.onApplySizePreset = { [weak self] preset in
MainActor.assumeIsolated { self?.applySizePreset(preset) }
}
hk.onSwitchToTab = { index in
MainActor.assumeIsolated { tm.switchToTab(at: index) }
}
hk.start()
}
// MARK: - Toggle
func toggleNotchOnActiveScreen() {
let mouseLocation = NSEvent.mouseLocation
let targetScreen = NSScreen.screens.first { NSMouseInRect(mouseLocation, $0.frame, false) }
?? NSScreen.main
guard let screen = targetScreen else { return }
let uuid = screen.displayUUID
// Close any other open notch first
for (otherUUID, otherVM) in viewModels where otherUUID != uuid {
if otherVM.notchState == .open {
closeNotch(screenUUID: otherUUID)
}
}
if let vm = viewModels[uuid] {
if vm.notchState == .open {
closeNotch(screenUUID: uuid)
} else {
openNotch(screenUUID: uuid)
}
}
}
// MARK: - Open / Close
func openNotch(screenUUID: String) {
guard let vm = viewModels[screenUUID],
let window = windows[screenUUID] else { return }
vm.cancelCloseTransition()
withAnimation(vm.openAnimation) {
vm.open()
}
window.isNotchOpen = true
HotkeyManager.shared.isNotchOpen = true
// Activate the app so the window can become key.
NSApp.activate(ignoringOtherApps: true)
window.makeKeyAndOrderFront(nil)
focusActiveTerminal(in: screenUUID)
}
func closeNotch(screenUUID: String) {
guard let vm = viewModels[screenUUID],
let window = windows[screenUUID] else { return }
vm.beginCloseTransition()
withAnimation(vm.closeAnimation) {
vm.close()
}
window.isNotchOpen = false
HotkeyManager.shared.isNotchOpen = false
}
private func detachActiveTab() {
if let session = TerminalManager.shared.detachActiveTab() {
DispatchQueue.main.async {
PopoutWindowController.shared.popout(session: session)
}
}
}
func applySizePreset(_ preset: TerminalSizePreset) {
guard let (screenUUID, vm) = viewModels.first(where: { $0.value.notchState == .open }) else {
UserDefaults.standard.set(preset.width, forKey: NotchSettings.Keys.openWidth)
UserDefaults.standard.set(preset.height, forKey: NotchSettings.Keys.openHeight)
return
}
let startSize = vm.notchSize
let targetSize = vm.setStoredOpenSize(preset.size)
animatePresetResize(for: screenUUID, from: startSize, to: targetSize, duration: vm.openAnimationDuration)
}
// 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 initialContentSize = vm.openNotchSize
let window = NotchWindow(
contentRect: NSRect(origin: .zero, size: CGSize(width: initialContentSize.width + 40, height: initialContentSize.height + 20)),
styleMask: [.borderless, .nonactivatingPanel, .utilityWindow],
backing: .buffered,
defer: false
)
// 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)
}
vm.requestWindowResize = { [weak self] in
self?.updateWindowFrame(for: uuid, centerHorizontally: true)
}
let hostingView = NSHostingView(
rootView: ContentView(vm: vm, terminalManager: TerminalManager.shared)
.preferredColorScheme(.dark)
)
let containerView = NSView(frame: NSRect(origin: .zero, size: window.frame.size))
containerView.autoresizesSubviews = true
containerView.wantsLayer = true
containerView.layer?.backgroundColor = NSColor.clear.cgColor
hostingView.frame = containerView.bounds
hostingView.autoresizingMask = [.width, .height]
containerView.addSubview(hostingView)
window.contentView = containerView
windows[uuid] = window
viewModels[uuid] = vm
updateWindowFrame(for: uuid, centerHorizontally: true)
window.orderFrontRegardless()
}
// 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()
updateWindowFrame(for: uuid, on: screen, window: window, centerHorizontally: true)
}
}
private func updateWindowFrame(for screenUUID: String, centerHorizontally: Bool = false) {
guard let screen = NSScreen.screens.first(where: { $0.displayUUID == screenUUID }),
let window = windows[screenUUID] else { return }
updateWindowFrame(for: screenUUID, on: screen, window: window, centerHorizontally: centerHorizontally)
}
private func updateWindowFrame(
for screenUUID: String,
on screen: NSScreen,
window: NotchWindow,
centerHorizontally: Bool = false
) {
let frame = targetWindowFrame(
for: screenUUID,
on: screen,
window: window,
centerHorizontally: centerHorizontally,
contentSize: nil
)
guard !window.frame.equalTo(frame) else { return }
window.setFrame(frame, display: false)
}
private func targetWindowFrame(
for screenUUID: String,
on screen: NSScreen,
window: NotchWindow,
centerHorizontally: Bool,
contentSize: CGSize?
) -> NSRect {
guard let vm = viewModels[screenUUID] else { return window.frame }
let shadowPadding: CGFloat = 20
let openSize = contentSize ?? vm.openNotchSize
let windowWidth = openSize.width + 40
let windowHeight = openSize.height + shadowPadding
let centeredX = screen.frame.origin.x + (screen.frame.width - windowWidth) / 2
let x: CGFloat = centerHorizontally || vm.notchState == .closed
? centeredX
: min(max(window.frame.minX, screen.frame.minX), screen.frame.maxX - windowWidth)
return NSRect(
x: x,
y: screen.frame.origin.y + screen.frame.height - windowHeight,
width: windowWidth,
height: windowHeight
)
}
private func animatePresetResize(
for screenUUID: String,
from startSize: CGSize,
to targetSize: CGSize,
duration: TimeInterval
) {
cancelPresetResize(for: screenUUID)
guard let vm = viewModels[screenUUID] else { return }
guard startSize != targetSize else {
vm.notchSize = targetSize
updateWindowFrame(for: screenUUID, centerHorizontally: true)
return
}
vm.isPresetResizing = true
let startTime = CACurrentMediaTime()
let duration = max(duration, presetResizeFrameInterval)
let timer = Timer(timeInterval: presetResizeFrameInterval, repeats: true) { [weak self] timer in
MainActor.assumeIsolated {
guard let self, let vm = self.viewModels[screenUUID] else {
timer.invalidate()
return
}
let elapsed = CACurrentMediaTime() - startTime
let progress = min(1, elapsed / duration)
let easedProgress = 0.5 - (cos(.pi * progress) / 2)
let size = CGSize(
width: startSize.width + ((targetSize.width - startSize.width) * easedProgress),
height: startSize.height + ((targetSize.height - startSize.height) * easedProgress)
)
vm.notchSize = size
self.updateWindowFrame(for: screenUUID, contentSize: size, centerHorizontally: true)
if progress >= 1 {
vm.notchSize = targetSize
vm.isPresetResizing = false
self.updateWindowFrame(for: screenUUID, contentSize: targetSize, centerHorizontally: true)
self.presetResizeTimers[screenUUID] = nil
timer.invalidate()
}
}
}
presetResizeTimers[screenUUID] = timer
RunLoop.main.add(timer, forMode: .common)
timer.fire()
}
private func cancelPresetResize(for screenUUID: String) {
presetResizeTimers[screenUUID]?.invalidate()
presetResizeTimers[screenUUID] = nil
viewModels[screenUUID]?.isPresetResizing = false
}
private func updateWindowFrame(
for screenUUID: String,
contentSize: CGSize,
centerHorizontally: Bool = false
) {
guard let screen = NSScreen.screens.first(where: { $0.displayUUID == screenUUID }),
let window = windows[screenUUID] else { return }
let frame = targetWindowFrame(
for: screenUUID,
on: screen,
window: window,
centerHorizontally: centerHorizontally,
contentSize: contentSize
)
guard !window.frame.equalTo(frame) else { return }
window.setFrame(frame, display: false)
}
// MARK: - Cleanup
private func cleanupAllWindows() {
for (_, timer) in presetResizeTimers {
timer.invalidate()
}
presetResizeTimers.removeAll()
for (_, window) in windows {
window.orderOut(nil)
window.close()
}
windows.removeAll()
viewModels.removeAll()
}
// MARK: - Screen observation
private func observeScreenChanges() {
NotificationCenter.default.publisher(for: NSApplication.didChangeScreenParametersNotification)
.debounce(for: .milliseconds(500), scheduler: RunLoop.main)
.sink { [weak self] _ in self?.handleScreenConfigurationChange() }
.store(in: &cancellables)
}
private func handleScreenConfigurationChange() {
let currentUUIDs = Set(NSScreen.screens.map { $0.displayUUID })
let knownUUIDs = Set(windows.keys)
if currentUUIDs != knownUUIDs {
rebuildWindows()
} else {
repositionWindows()
}
}
private func focusActiveTerminal(in screenUUID: String, attemptsRemaining: Int = 12) {
guard let window = windows[screenUUID],
let terminalView = TerminalManager.shared.activeTab?.terminalView else { return }
if terminalView.window === window {
window.makeFirstResponder(terminalView)
return
}
guard attemptsRemaining > 0 else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + focusRetryDelay) { [weak self] in
self?.focusActiveTerminal(in: screenUUID, attemptsRemaining: attemptsRemaining - 1)
}
}
}

View File

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

View File

@@ -1,118 +0,0 @@
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
@AppStorage(NotchSettings.Keys.terminalTheme)
private var theme: String = NotchSettings.Defaults.terminalTheme
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),
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
}
}
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)
}
}
func updateAllThemes(_ theme: TerminalTheme) {
for tab in tabs {
tab.applyTheme(theme)
}
}
}

View File

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

View File

@@ -1,48 +0,0 @@
name: CommandNotch
options:
bundleIdPrefix: com.commandnotch
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:
CommandNotch:
type: application
platform: macOS
sources:
- path: CommandNotch
excludes:
- Resources/Info.plist
dependencies:
- package: SwiftTerm
info:
path: CommandNotch/Resources/Info.plist
properties:
CFBundleName: CommandNotch
CFBundleDisplayName: CommandNotch
CFBundleIdentifier: com.commandnotch.app
CFBundleVersion: "1"
CFBundleShortVersionString: "0.2.0"
CFBundlePackageType: APPL
CFBundleExecutable: CommandNotch
LSMinimumSystemVersion: "14.0"
LSUIElement: true
NSHumanReadableCopyright: "Copyright © 2026 CommandNotch. All rights reserved."
entitlements:
path: CommandNotch/Resources/CommandNotch.entitlements
settings:
base:
CODE_SIGN_ENTITLEMENTS: CommandNotch/Resources/CommandNotch.entitlements
INFOPLIST_FILE: CommandNotch/Resources/Info.plist
PRODUCT_BUNDLE_IDENTIFIER: com.commandnotch.app
PRODUCT_NAME: CommandNotch
COMBINE_HIDPI_IMAGES: true

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Harvey Zuccon
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

108
README.md Normal file
View File

@@ -0,0 +1,108 @@
# Downterm
<p align="center">
<img src="icons/Downterm-icon-256.png" width="128" alt="Downterm icon">
</p>
<p align="center">
A drop-down terminal for macOS that lives in the notch area.
</p>
<p align="center">
<img src="https://img.shields.io/badge/platform-macOS%2014%2B-black" alt="macOS 14+">
<img src="https://img.shields.io/badge/UI-SwiftUI%20%2B%20AppKit-black" alt="SwiftUI and AppKit">
<img src="https://img.shields.io/badge/license-MIT-black" alt="MIT License">
</p>
Downterm is a notch-native terminal overlay for macOS. It gives you a fast shell without switching spaces, keeping a full Terminal window open, or breaking your flow. Open it with a hotkey, drop into a shell, then get out of the way just as quickly.
The current Xcode target and bundle name are still `CommandNotch`, but the project is being presented publicly as **Downterm**.
## Why Downterm
- You want a terminal that is always one shortcut away.
- You like the idea of a terminal living in the menu bar / notch area instead of a full window.
- You work across multiple displays and want each screen to keep its own notch state.
- You want lightweight workspaces, detachable tabs, and shell access without giving up native macOS feel.
## Features
- Native macOS notch overlay with open and closed states.
- Fast shell sessions powered by SwiftTerm and a local login shell.
- Multiple tabs with hotkeys for new, close, next, previous, and direct tab switching.
- Workspace support, including shared workspaces across screens.
- Multi-display awareness with per-screen assignment and presentation state.
- Detachable tabs that can pop out into standalone terminal windows.
- Terminal themes: Classic, Xterm, Solarized Dark, Dracula, and Nord.
- Configurable terminal size presets with optional hotkeys.
- Hover-to-open behavior and animation tuning.
- Launch at login support.
- Global toggle hotkey and notch-scoped shortcut handling.
- Terminal-friendly macOS key behavior for `Command+Arrow`, `Option+Arrow`, `Command+Backspace`, and `Command+L`.
## Gallery
### Open Notch Terminal
![Downterm open notch terminal](.github/assets/downterm-open.png)
### Settings
![Downterm settings window](.github/assets/downterm-settings.png)
## Getting Started
### Requirements
- macOS 14 or later
- Xcode 16 or later
- Homebrew `xcodegen`
### Build
```bash
cd Downterm
xcodegen generate --spec project.yml
open CommandNotch.xcodeproj
```
Or from the command line:
```bash
cd Downterm
xcodegen generate --spec project.yml
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \
xcodebuild build -project CommandNotch.xcodeproj -scheme CommandNotch -destination 'platform=macOS'
```
## Project Layout
```text
Downterm/
├── Downterm/ # XcodeGen spec, app target, tests
├── docs/ # architecture and planning notes
├── icons/ # app icons and branding assets
└── .github/assets/ # README screenshots and support assets
```
## Contributing
Contributions are welcome. If you want to fix bugs, improve the UX, tighten the architecture, or help polish the public release, start with [CONTRIBUTING.md](CONTRIBUTING.md).
## Help Fund Development
If Downterm saves you time, support helps justify more polish and faster iteration.
- Ko-fi: `ADD_YOUR_KOFI_LINK_HERE`
- BCH: `ADD_YOUR_BCH_ADDRESS_HERE`
> Funding placeholders are intentionally left unfinalized until the maintainer adds the real Ko-fi URL and BCH address.
> Replace the BCH placeholder above and swap in a real QR image before publishing this section widely.
<p align="center">
<img src=".github/assets/bch-qr-placeholder.svg" width="220" alt="Placeholder BCH QR card">
</p>
## License
Downterm is released under the [MIT License](LICENSE).

View File

@@ -0,0 +1,885 @@
# Workspace Architecture Spec
## Purpose
This document defines the target architecture for refactoring CommandNotch into a maintainable, testable macOS app with:
- explicit runtime state ownership
- virtual workspaces shared across screens
- typed settings instead of `UserDefaults` as the event bus
- reduced singleton coupling
- a realistic testing strategy for logic, integration, and UI
This is the implementation reference for the refactor.
## Goals
- Preserve the current product behavior where possible.
- Support multiple virtual workspaces.
- Allow multiple screens to point at the same workspace.
- Allow screens to switch workspaces independently.
- Keep screen-specific UI state local to each screen.
- Make business logic unit testable without real `NSWindow`, `NSScreen`, or `UserDefaults`.
- Keep AppKit/SwiftUI at the edges.
## Non-Goals
- Multi-window rendering of the same live terminal instance at the same time.
- Full state restoration of shell process contents across launches.
- Perfect backward compatibility for internal architecture.
- Solving all visual polish issues during the refactor.
## Constraints
- A `TerminalView` cannot safely belong to more than one window/view hierarchy at once.
- A `LocalProcess`-backed session is inherently stateful and UI-coupled through SwiftTerm.
- The app is macOS-only and already depends on AppKit + SwiftUI + SwiftTerm.
## Target Mental Model
There are three layers of state:
1. Global app state
Global settings, hotkeys, launch behavior, workspace metadata, screen assignments.
2. Workspace state
Tabs, active tab, terminal sessions, workspace title, workspace-local behavior.
3. Screen state
Which workspace a screen is viewing, notch geometry, open/closed state, hover state, focus state.
The current app blurs these boundaries. The refactor makes them explicit.
## High-Level Architecture
### Composition Root
`AppDelegate` becomes the composition root. It wires concrete implementations together and passes them into coordinators/controllers. It should be the only place that knows about most concrete types.
### Core Modules
- `AppSettingsStore`
Loads and saves global settings. Publishes typed changes.
- `WorkspaceRegistry`
Owns all workspaces and workspace metadata.
- `ScreenRegistry`
Tracks connected screens and their screen-local state.
- `NotchOrchestrator`
Coordinates notch lifecycle per screen.
- `WindowCoordinator`
Owns AppKit windows/panels and binds them to screen models.
- `HotkeyService`
Registers global and local hotkeys and emits typed intents.
- `SettingsCoordinator`
Presents settings UI.
- `PopoutCoordinator`
Presents detached terminal windows.
### UI Boundaries
- SwiftUI views render state and emit intents only.
- AppKit controllers own `NSWindow`, `NSPanel`, `NSHostingView`.
- SwiftTerm integration is behind a terminal session abstraction.
## Ownership Model
### AppSettings
Owns:
- app-wide appearance settings
- hotkey bindings
- launch-at-login
- menu bar visibility
- default workspace behavior
- workspace assignment persistence
Does not own:
- transient window geometry
- active terminal sessions
- per-screen hover/open state
### Workspace
Owns:
- `workspaceID`
- display name
- ordered tabs
- active tab selection
- detached tab metadata if retained
- workspace-local terminal state
Does not own:
- screen assignment
- notch geometry
- whether a screen is open or closed
### ScreenContext
Owns:
- `screenID`
- assigned `workspaceID`
- notch presentation state
- current notch frame/size
- hover state
- local focus state
- local transition state
Does not own:
- tabs
- terminal session collection
- app-wide settings
## Required Domain Types
### Identifiers
```swift
typealias WorkspaceID = UUID
typealias ScreenID = String
typealias TabID = UUID
typealias SessionID = UUID
```
### App Settings
```swift
struct AppSettings: Equatable, Codable {
var showMenuBarIcon: Bool
var showOnAllDisplays: Bool
var launchAtLogin: Bool
var appearance: AppearanceSettings
var animation: AnimationSettings
var terminal: TerminalSettings
var hotkeys: HotkeySettings
}
```
### Workspace Summary
```swift
struct WorkspaceSummary: Equatable, Codable, Identifiable {
var id: WorkspaceID
var name: String
var createdAt: Date
}
```
### Workspace Assignment
```swift
struct ScreenWorkspaceAssignment: Equatable, Codable {
var screenID: ScreenID
var workspaceID: WorkspaceID
}
```
### Screen UI State
```swift
struct ScreenUIState: Equatable {
var screenID: ScreenID
var workspaceID: WorkspaceID
var notchState: NotchPresentationState
var notchSize: CGSize
var closedNotchSize: CGSize
var isHovering: Bool
var isFocused: Bool
var transitionState: NotchTransitionState
}
```
### Workspace State
```swift
struct WorkspaceState: Identifiable {
var id: WorkspaceID
var name: String
var tabs: [TerminalTabState]
var activeTabID: TabID?
}
```
### Tab State
```swift
struct TerminalTabState: Identifiable {
var id: TabID
var sessionID: SessionID
var title: String
}
```
### Notch Lifecycle State
```swift
enum NotchPresentationState: Equatable {
case closed
case open
}
enum NotchTransitionState: Equatable {
case idle
case opening
case closing
case resizingUser
case resizingPreset
}
```
## Target Runtime Objects
### `AppController`
Top-level coordinator created by `AppDelegate`.
Responsibilities:
- boot app services
- respond to lifecycle events
- connect hotkey intents to workspace/screen actions
- own references to main long-lived services
### `WorkspaceRegistry`
Responsibilities:
- create workspace
- delete workspace
- rename workspace
- fetch workspace by id
- publish workspace list changes
- ensure at least one workspace exists
Suggested API:
```swift
@MainActor
protocol WorkspaceRegistryType: AnyObject {
var workspaceSummariesPublisher: AnyPublisher<[WorkspaceSummary], Never> { get }
func allWorkspaceSummaries() -> [WorkspaceSummary]
func workspaceController(for id: WorkspaceID) -> WorkspaceControllerType?
func createWorkspace(named: String?) -> WorkspaceID
func deleteWorkspace(id: WorkspaceID)
func renameWorkspace(id: WorkspaceID, name: String)
func ensureWorkspaceExists() -> WorkspaceID
}
```
### `WorkspaceController`
Replaces most of `TerminalManager`.
Responsibilities:
- own tabs for a single workspace
- create and close tabs
- detach tabs
- switch active tab
- update session appearance when settings change
- publish workspace state
Suggested API:
```swift
@MainActor
protocol WorkspaceControllerType: ObservableObject {
var state: WorkspaceState { get }
func newTab()
func closeTab(id: TabID)
func closeActiveTab()
func switchToTab(id: TabID)
func switchToTab(index: Int)
func nextTab()
func previousTab()
func detachActiveTab() -> TerminalSessionType?
func updateTheme(_ theme: TerminalTheme)
func updateFontSize(_ size: CGFloat)
}
```
### `ScreenRegistry`
Responsibilities:
- discover connected screens
- maintain `ScreenContext` for each screen
- maintain screen-to-workspace assignment
- rebuild state on screen changes
Suggested API:
```swift
@MainActor
protocol ScreenRegistryType: AnyObject {
var screenContextsPublisher: AnyPublisher<[ScreenContext], Never> { get }
func allScreens() -> [ScreenContext]
func screenContext(for id: ScreenID) -> ScreenContext?
func assignWorkspace(_ workspaceID: WorkspaceID, to screenID: ScreenID)
func activeScreenID() -> ScreenID?
func refreshConnectedScreens()
}
```
### `ScreenContext`
Observable object for one physical display.
Responsibilities:
- store local UI state
- expose a current `workspaceID`
- emit user intents for local notch behavior
This should replace todays overloaded `NotchViewModel`.
### `NotchOrchestrator`
Responsibilities:
- open/close notch for a screen
- coordinate focus, hover, suppression, and transitions
- enforce transition rules
- drive frame updates indirectly through window coordination
The orchestrator should own the state machine, not `ContentView`.
### `WindowCoordinator`
Responsibilities:
- create/destroy one `NotchWindow` per screen
- bind `ScreenContext` to window content
- update window frames
- respond to `resignKey`
- focus the correct terminal view
This keeps AppKit-specific code out of registry/controller classes.
### `HotkeyService`
Responsibilities:
- register global hotkey bindings
- observe only hotkey-related settings changes
- emit typed commands
Suggested intent enum:
```swift
enum AppCommand {
case toggleNotch(screenID: ScreenID?)
case newTab(workspaceID: WorkspaceID)
case closeTab(workspaceID: WorkspaceID)
case nextTab(workspaceID: WorkspaceID)
case previousTab(workspaceID: WorkspaceID)
case switchToTab(workspaceID: WorkspaceID, index: Int)
case detachTab(workspaceID: WorkspaceID)
case applySizePreset(screenID: ScreenID, presetID: UUID)
case switchWorkspace(screenID: ScreenID, workspaceID: WorkspaceID)
case createWorkspace(screenID: ScreenID)
}
```
## Terminal Session Design
### Current Problem
`TerminalSession` mixes:
- process lifecycle
- SwiftTerm view ownership
- delegate callbacks
- some UI-facing published state
This makes reuse difficult.
### Target Split
Introduce:
- `TerminalSession`
Process + session metadata + delegate event translation
- `TerminalViewHost`
Owns a `TerminalView` for one active screen/window
Minimal first-step compromise:
- Keep `TerminalSession` owning one `TerminalView`
- Enforce that only one screen at a time can actively render a given workspace
- Make that rule explicit in the architecture
Recommended v1 rule:
- many screens may point at the same workspace
- only one screen may have that workspace open and focused at a time
This avoids trying to mirror one live terminal view into multiple windows.
## Settings Design
### Current Problem
`@AppStorage` is scattered across many views and managers, and `UserDefaults` changes trigger broad runtime work.
### Target Design
Introduce:
- `AppSettingsStore`
Persistence boundary
- `AppSettingsController`
In-memory observable runtime settings
Suggested pattern:
```swift
protocol AppSettingsStoreType {
func load() -> AppSettings
func save(_ settings: AppSettings)
}
@MainActor
final class AppSettingsController: ObservableObject {
@Published private(set) var settings: AppSettings
func update(_ update: (inout AppSettings) -> Void)
}
```
Views bind to `AppSettingsController`.
Services subscribe to precise sub-slices of settings.
## Persistence Design
Persist only durable data:
- app settings
- workspace summaries
- screen-to-workspace assignments
- optional workspace-local preferences
- size presets
Do not persist:
- live `LocalProcess`
- active shell buffer contents
- transient hover/focus/transition state
Suggested storage keys:
- `appSettings`
- `workspaceSummaries`
- `screenAssignments`
- `terminalSizePresets`
Prefer a single encoded settings object per concern over many independent keys.
## UI Structure
### Root Views
- `NotchRootView`
- `ClosedNotchView`
- `OpenWorkspaceView`
- `TabStripView`
- `WorkspacePickerView`
- `SettingsRootView`
### View Rules
- Views may read state from observable objects.
- Views may emit intents via closures or controller methods.
- Views do not call global singletons.
- Views do not own delayed business logic unless it is purely visual.
## Workspace UX Rules
- Every screen always has an assigned workspace.
- A new screen defaults to:
- the app default workspace strategy, or
- the first existing workspace
- Users can:
- switch a screen to another workspace
- create a new workspace from a screen
- rename a workspace
- Deleting a workspace:
- reassigns affected screens to a fallback workspace
- is disallowed if it is the last workspace
## Screen UX Rules
- A screen can be open/closed independently of other screens.
- Two screens may point at the same workspace.
- If a workspace is already open on another screen:
- opening it on this screen should either transfer focus, or
- close it on the other screen first
Recommended v1 behavior:
- one active open screen per workspace
- opening workspace `W` on screen `B` closes `W` on screen `A`
That is simple, deterministic, and compatible with single `TerminalView` ownership.
## Migration Plan
### Phase 0: Hygiene
- remove dead settings and unused properties
- fix version drift
- fix stale comments
- add test targets
- isolate root build artifacts from source tree if desired
### Phase 1: Settings Layer
- create `AppSettings`, `AppSettingsStore`, `AppSettingsController`
- move scattered `@AppStorage` usage into typed settings reads/writes
- make `HotkeyService` observe only hotkey settings
- make window sizing observe only relevant settings
Exit criteria:
- no runtime logic depends on `UserDefaults.didChangeNotification`
### Phase 2: Workspace Core
- add `WorkspaceSummary`, `WorkspaceRegistry`, `WorkspaceController`
- migrate current `TerminalManager` logic into `WorkspaceController`
- ensure one default workspace exists
Exit criteria:
- tabs exist inside a workspace object, not globally
### Phase 3: Screen Core
- add `ScreenContext`, `ScreenRegistry`
- migrate current `NotchViewModel` responsibilities into `ScreenContext`
- persist screen-to-workspace assignments
Exit criteria:
- screen-local state is independent from workspace state
### Phase 4: Notch Lifecycle Orchestrator
- move hover/open/close/transition logic out of `ContentView`
- add explicit transition states
- centralize focus/open/close sequencing
Exit criteria:
- `ContentView` becomes mostly rendering + view intents
### Phase 5: Window Coordination
- extract AppKit window creation and frame management into `WindowCoordinator`
- keep `ScreenManager` as thin glue or replace it entirely
Exit criteria:
- no window-specific behavior in workspace logic
### Phase 6: Workspace Switching UX
- add workspace picker UI
- support create/switch/rename/delete workspaces
- enforce one-open-screen-per-workspace behavior
Exit criteria:
- a user can assign the same workspace to multiple screens
### Phase 7: Popout and Session Cleanup
- formalize detached tab ownership
- remove session observer leaks
- remove app-global current-directory mutation
Exit criteria:
- session lifetime is explicit and testable
### Phase 8: Test Expansion
- add unit coverage for all core models/controllers
- add integration tests for orchestrators
- add XCUITests for key user flows
## Proposed File Layout
```text
Downterm/CommandNotch/
App/
CommandNotchApp.swift
AppDelegate.swift
AppController.swift
Core/
Settings/
AppSettings.swift
AppSettingsController.swift
AppSettingsStore.swift
UserDefaultsAppSettingsStore.swift
Workspaces/
WorkspaceSummary.swift
WorkspaceState.swift
WorkspaceRegistry.swift
WorkspaceController.swift
WorkspaceStore.swift
Screens/
ScreenContext.swift
ScreenRegistry.swift
ScreenAssignmentStore.swift
Notch/
NotchOrchestrator.swift
NotchStateMachine.swift
NotchFrameCalculator.swift
Terminal/
TerminalSession.swift
TerminalSessionFactory.swift
TerminalTheme.swift
TerminalTabState.swift
Hotkeys/
HotkeyService.swift
HotkeyBinding.swift
AppCommand.swift
UI/
Notch/
NotchRootView.swift
ClosedNotchView.swift
OpenWorkspaceView.swift
TabStripView.swift
WorkspacePickerView.swift
Settings/
SettingsRootView.swift
GeneralSettingsView.swift
AppearanceSettingsView.swift
AnimationSettingsView.swift
TerminalSettingsView.swift
HotkeySettingsView.swift
WorkspaceSettingsView.swift
Components/
SwiftTermView.swift
HotkeyRecorderView.swift
NotchShape.swift
AppKit/
WindowCoordinator.swift
NotchWindow.swift
SettingsCoordinator.swift
PopoutCoordinator.swift
ScreenProvider.swift
Persistence/
CodableStore.swift
Extensions/
NSScreen+Extensions.swift
```
## Protocol Boundaries Required For Tests
Create protocols around:
- settings store
- workspace store
- screen provider
- window factory
- hotkey registrar
- launch-at-login service
- terminal session factory
- clock/scheduler for delayed hover/open/close logic
Examples:
```swift
protocol ScreenProviderType {
var screens: [ScreenDescriptor] { get }
}
protocol WindowCoordinatorType {
func showWindow(for screenID: ScreenID)
func hideWindow(for screenID: ScreenID)
func updateFrame(for screenID: ScreenID, frame: CGRect)
func focusTerminal(for screenID: ScreenID)
}
protocol SchedulerType {
func schedule(after interval: TimeInterval, _ action: @escaping () -> Void) -> Cancellable
}
```
## Testing Strategy
## 1. Unit Tests
Add a `CommandNotchTests` target.
Focus on pure logic:
- `WorkspaceRegistryTests`
- creates default workspace
- cannot delete last workspace
- rename/delete/create behavior
- `WorkspaceControllerTests`
- new tab
- close active tab
- detach active tab
- active tab index/id updates correctly
- `ScreenRegistryTests`
- new screen gets valid assignment
- assignment changes persist
- missing workspace reassigns to fallback
- `NotchStateMachineTests`
- closed -> opening -> open
- close suppression while hovering
- resizing transitions
- `NotchFrameCalculatorTests`
- clamping
- per-screen max bounds
- closed-notch size behavior
- `HotkeyBindingTests`
- event matching
- preset digit mapping
- `AppSettingsControllerTests`
- only targeted changes publish
## 2. Integration Tests
Use fakes for AppKit boundaries.
- `NotchOrchestratorIntegrationTests`
- opening a workspace on one screen closes it on another screen if shared
- focus transfer rules
- preset resize behavior
- `AppControllerIntegrationTests`
- hotkey command routes to active screen/workspace
- settings changes update workspaces without broad side effects
## 3. UI Tests
Add a `CommandNotchUITests` target with XCUITest.
Recommended initial flows:
- launch app and open settings
- create workspace from settings or notch UI
- assign workspace to another screen context if testable
- create tab
- switch tabs
- apply size preset
- detach active tab
UI tests should validate behavior and existence, not exact pixel values.
## 4. Optional Snapshot Tests
Only add after state is injectable.
Snapshot candidates:
- closed notch
- open notch with one tab
- workspace picker
- settings panes
Do not start here.
## Testing SwiftUI/AppKit UI
### What to test directly
- view model/controller behavior
- user intents emitted by the view
- XCUITest end-to-end flows
### What not to over-invest in early
- exact animation curves
- fragile layout pixel assertions
- direct testing of `NSWindow` internals unless wrapped behind protocols
### Practical answer
For this app, “testing UI” mostly means testing the logic that drives the UI, plus a thin XCUITest layer that proves the main flows work.
## Current Issues Mapped To Fixes
- Global singleton tab state
Fix with `WorkspaceRegistry` + `WorkspaceController`
- `UserDefaults` as runtime bus
Fix with typed settings controller and targeted subscriptions
- Dead gesture settings
Remove or implement later behind a dedicated feature
- Observer leakage in tab creation
Store cancellables per session or derive titles from workspace state
- App-global cwd mutation
Remove from session startup; set process cwd explicitly if supported
- Monolithic settings file
Split into feature files bound to typed settings
- Timing-based UI behavior spread across layers
Centralize in `NotchOrchestrator`
- No tests
Add unit + integration + XCUITest targets
## Implementation Checklist
- [ ] Add `CommandNotchTests`
- [ ] Add `CommandNotchUITests`
- [ ] Create `AppSettings` model
- [ ] Create `AppSettingsStore`
- [ ] Replace broad defaults observation
- [ ] Create `WorkspaceRegistry`
- [ ] Create `WorkspaceController`
- [ ] Move tab logic out of `TerminalManager`
- [ ] Create `ScreenContext`
- [ ] Create `ScreenRegistry`
- [ ] Persist screen assignments
- [ ] Create `NotchOrchestrator`
- [ ] Create `NotchFrameCalculator`
- [ ] Extract `WindowCoordinator`
- [ ] Add workspace picker UI
- [ ] Add create/switch/rename/delete workspace flows
- [ ] Enforce one-open-screen-per-workspace
- [ ] Clean up detached session ownership
- [ ] Split settings views into separate files
- [ ] Remove dead settings and unused code
- [ ] Add unit tests for registry/controller/state machine/frame calculator
- [ ] Add initial XCUITest flows
## First Concrete Refactor Slice
If implementing incrementally, the best first slice is:
1. add tests target
2. add `AppSettings`
3. add `WorkspaceRegistry` with a single default workspace
4. migrate current `TerminalManager` into a first `WorkspaceController`
5. change the app so all current behavior still uses one workspace
That gives immediate architectural improvement without changing UX yet.
## Definition Of Done
The refactor is successful when:
- there is no critical runtime logic driven by broad `UserDefaults` notifications
- tabs/sessions live under workspaces, not globally
- screens are explicitly assigned to workspaces
- the same workspace can be selected on multiple screens
- only one open/focused screen owns a workspaces live terminal at a time
- window behavior is separated from domain logic
- unit tests cover the core state machines and registries
- XCUITests cover main user flows

View File

@@ -0,0 +1,351 @@
# Workspace Split Pane Plan
## Purpose
Capture the agreed design for workspace-owned split terminals so the feature can be implemented later without redoing scope and architecture decisions.
## Agreed Scope
- Split layouts belong to the workspace, not the screen.
- A workspace may only be actively presented on one screen at a time.
- Split layouts should be visible the same way when that workspace is opened on another monitor.
- Nested splits are supported.
- Any two tabs may be joined into a split.
- Closing a pane causes its sibling to expand.
- Split layout must survive relaunch.
## Non-Goals
- Simultaneous live rendering of the same workspace on multiple screens.
- Full multi-user or collaborative state sync.
- Arbitrary freeform pane layout. The model is tree-based binary splits.
- Solving all interaction polish in the first pass.
## Current Constraint
The current workspace model is flat:
- `WorkspaceController` owns `[TerminalSession]`
- one tab is active at a time
- `ContentView` renders exactly one active `SwiftTermView`
This means split panes are not an additive UI feature. They require a workspace model refactor.
## Recommended Model
Do not model splits as "adjacent tabs with special behavior" internally.
Instead:
1. A workspace owns ordered top-level tabs.
2. Each top-level tab owns a pane tree.
3. Pane tree leaves own `TerminalSession` instances.
4. Pane tree internal nodes represent binary splits.
5. The tab bar can visually present grouped/joined tabs based on the active tab's pane composition.
This keeps the domain model clean while still allowing the desired visual metaphor.
## Proposed Domain Types
```swift
typealias WorkspaceTabID = UUID
typealias PaneID = UUID
struct WorkspaceTab: Identifiable, Equatable, Codable {
var id: WorkspaceTabID
var rootPane: PaneNode
var selectedPaneID: PaneID?
var titleMode: WorkspaceTabTitleMode
}
enum PaneNode: Equatable, Codable, Identifiable {
case leaf(PaneLeaf)
case split(PaneSplit)
var id: PaneID { ... }
}
struct PaneLeaf: Equatable, Codable, Identifiable {
var id: PaneID
var sessionID: UUID
}
struct PaneSplit: Equatable, Codable, Identifiable {
var id: PaneID
var axis: SplitAxis
var ratio: Double
var first: PaneNode
var second: PaneNode
}
enum SplitAxis: String, Codable {
case horizontal
case vertical
}
```
Notes:
- `ratio` should be persisted so user-adjusted divider positions survive relaunch.
- `selectedPaneID` allows focus-based commands such as split active pane, close active pane, and move focus.
- The persisted model should store session identity separately from runtime AppKit/SwiftTerm objects.
## Runtime Ownership
Global:
- workspace summaries
- workspace ordering
- workspace-to-screen assignment
- hotkeys
Workspace:
- ordered top-level tabs
- active top-level tab
- per-tab pane tree
- active/selected pane
- terminal sessions for pane leaves
Screen:
- which workspace is assigned
- notch open/close state
- geometry and transitions
This remains aligned with the existing workspace architecture.
## Persistence Strategy
Split layout must survive relaunch, so workspace persistence needs to grow beyond summary metadata.
Recommended approach:
1. Keep `WorkspaceSummary` for list metadata only.
2. Introduce a persisted `WorkspaceDocument` or `WorkspaceSnapshot` per workspace.
3. Persist:
- top-level tabs
- pane tree
- active tab ID
- selected pane ID
- divider ratios
4. Do not attempt to persist shell process contents.
5. On relaunch, recreate sessions for pane leaves and restore layout structure only.
Important distinction:
- layout persistence is required
- terminal process continuity is not
## UI Model
### Main Content
The active top-level tab renders as a recursive split tree.
- leaf node -> one `SwiftTermView`
- split node -> `HSplitView`/`VSplitView` equivalent SwiftUI container with draggable divider
### Tab Bar
Top-level tabs remain the primary navigation unit.
Visual behavior:
- a normal unsplit tab looks like today
- a joined/split tab should look grouped
- grouped tabs should expose child pane titles visually inside the tab item
Recommended first-pass appearance:
- one outer tab pill per top-level tab
- inside that pill, show compact child title chips for each leaf pane
- highlight the selected pane's chip
This gives the "tabs are joined" feel without making pane leaves first-class top-level tabs in the data model.
## Command Model
New actions likely needed:
- split active pane horizontally
- split active pane vertically
- focus next pane
- focus previous pane
- close active pane
- resize focused split divider
- join tab A into tab B
- detach pane into new top-level tab
Semantics:
- joining any two tabs merges one tab's root pane into the other's tree
- the source top-level tab is removed
- the destination top-level tab remains
- sibling expansion on close is standard tree collapse
## Join Semantics
Joining any two tabs should work as:
1. Choose destination tab.
2. Choose source tab.
3. Replace destination root with a new split node:
- first child = old destination root
- second child = source root
4. Remove source tab from workspace order.
5. Select a predictable pane, preferably the source pane that was just added.
An explicit split axis should be required for the join action.
## Close Semantics
When closing a pane:
- if the pane has a sibling, the sibling expands into the parent's position
- if the pane was the only leaf in a top-level tab:
- close the whole top-level tab if more than one tab exists
- otherwise create a replacement shell pane, matching current single-tab safety behavior
## Focus Semantics
Pane focus must become explicit.
Recommended rules:
- mouse click focuses that pane
- splitting focuses the new pane
- joining focuses the moved-in pane
- closing a pane focuses the surviving sibling
- top-level tab switch restores the previously selected pane in that tab
## Migration Path
Implement in stages to reduce risk.
### Stage 1: Domain Refactor
- Replace flat workspace tabs with top-level tab objects.
- Introduce pane tree types.
- Keep only one leaf per tab initially so behavior is unchanged.
### Stage 2: Runtime Layout Rendering
- Render pane trees recursively in `ContentView`.
- Add active pane selection.
- Keep persistence off until runtime behavior is stable.
### Stage 3: Split Actions
- Split active pane horizontally/vertically.
- Close pane with sibling expansion.
- Basic focus movement.
### Stage 4: Joined Tab UI
- Update tab bar to show grouped child pane chips.
- Surface active pane clearly.
### Stage 5: Join / Unjoin Flows
- Join any two tabs with explicit axis choice.
- Support promoting a pane back to its own top-level tab if needed.
### Stage 6: Persistence
- Persist pane trees and top-level tab state.
- Recreate sessions on launch.
### Stage 7: Hotkeys and Polish
- Add pane-focused shortcuts.
- Add divider dragging polish.
- Improve visual grouped-tab affordances.
## Main Risks
### 1. Model complexity
The current flat `[TerminalSession]` model is simple. Tree-based layout introduces more state, more edge cases, and more focus semantics.
Mitigation:
- refactor the data model before touching complex UI
- keep top-level tabs and pane leaves distinct
### 2. SwiftTerm view ownership
`TerminalView` cannot be mounted in multiple places safely.
Mitigation:
- preserve current rule: only one active presenting screen per workspace
- keep one runtime `TerminalSession` per pane leaf
### 3. Persistence mismatch
Persisting layout is easy compared with persisting process state.
Mitigation:
- persist layout and selection only
- recreate shell sessions on launch
### 4. Joined-tab UX ambiguity
If grouped tabs are also used as pane chips, the interaction model can get confusing.
Mitigation:
- preserve top-level tabs as the real navigation unit
- use internal chips only as secondary indicators/actions
## Recommended First Implementation Boundary
The first deliverable should include:
- nested binary split trees
- active pane focus
- split and close pane actions
- sibling expansion
- grouped tab appearance
- join any two tabs
- persisted layout across relaunch
- same workspace layout when moved to another screen
The first deliverable should not include:
- simultaneous same-workspace rendering on multiple screens
- drag-and-drop tree editing
- restoring running shell process contents
## Files Likely Impacted
- `Downterm/CommandNotch/Models/WorkspaceController.swift`
- `Downterm/CommandNotch/Models/WorkspaceRegistry.swift`
- `Downterm/CommandNotch/Models/WorkspaceStore.swift`
- `Downterm/CommandNotch/ContentView.swift`
- `Downterm/CommandNotch/Components/TabBar.swift`
- `Downterm/CommandNotch/Models/TerminalSession.swift`
- workspace-related tests
Likely new files:
- pane tree domain types
- persisted workspace document types
- split-pane rendering view(s)
- pane-focused command helpers
## Decision Summary
The feature is feasible.
The correct architecture is:
- workspace-owned split tree
- top-level tabs remain the primary unit
- grouped tab visuals are a UI layer over the pane tree
- one presenting screen per workspace
- persisted layout, not persisted process contents
That is the implementation direction to use when this work is resumed.