Initial commit: macOS terminal app with vertical tabs using libghostty
Features: - Vertical tabs sidebar with SwiftUI - Terminal emulation via GhosttyKit.xcframework (libghostty) - Keyboard shortcuts: Cmd+T/N, Ctrl+Shift+` (new tab), Cmd+W (close), Cmd+Shift+[/], Ctrl+Tab (navigation), Cmd+1-9 (jump to tab) - Reads Ghostty config from ~/Library/Application Support/com.mitchellh.ghostty/config - Metal-based rendering
This commit is contained in:
commit
c5bd543fe0
14 changed files with 2682 additions and 0 deletions
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
# Xcode
|
||||||
|
.build/
|
||||||
|
DerivedData/
|
||||||
|
*.xcuserstate
|
||||||
|
xcuserdata/
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Swift Package Manager
|
||||||
|
.swiftpm/
|
||||||
|
|
||||||
|
# GhosttyKit binary (rebuild from /tmp/ghostty with zig build)
|
||||||
|
GhosttyKit.xcframework/
|
||||||
47
CLAUDE.md
Normal file
47
CLAUDE.md
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
# GhosttyTabs
|
||||||
|
|
||||||
|
A macOS terminal app with vertical tabs, using libghostty (GhosttyKit.xcframework) for terminal emulation.
|
||||||
|
|
||||||
|
## User Preferences
|
||||||
|
|
||||||
|
- **Always use Release builds** when building and launching for testing
|
||||||
|
- Build libghostty with `-Doptimize=ReleaseFast` for performance
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Build and launch (Release)
|
||||||
|
```bash
|
||||||
|
cd /Users/lawrencechen/fun/cmux-terminal/GhosttyTabs
|
||||||
|
pkill -9 GhosttyTabs 2>/dev/null
|
||||||
|
xcodebuild -scheme GhosttyTabs -configuration Release build
|
||||||
|
open ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-cbjivvtpirygxbbgqlpdpiiyjnwh/Build/Products/Release/GhosttyTabs.app
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rebuild libghostty (optimized)
|
||||||
|
```bash
|
||||||
|
cd /tmp/ghostty
|
||||||
|
zig build -Demit-xcframework=true -Dxcframework-target=native -Doptimize=ReleaseFast
|
||||||
|
cp -R /tmp/ghostty/macos/GhosttyKit.xcframework /Users/lawrencechen/fun/cmux-terminal/GhosttyTabs/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Project structure
|
||||||
|
- `Sources/` - Swift source files
|
||||||
|
- `GhosttyTabsApp.swift` - App entry point with keyboard shortcuts
|
||||||
|
- `ContentView.swift` - Main UI with vertical tabs sidebar
|
||||||
|
- `TabManager.swift` - Tab state management
|
||||||
|
- `GhosttyTerminalView.swift` - libghostty terminal integration
|
||||||
|
- `GhosttyConfig.swift` - Ghostty config parser
|
||||||
|
- `GhosttyKit.xcframework/` - libghostty static library
|
||||||
|
- `ghostty.h` - Ghostty C API header
|
||||||
|
- `GhosttyTabs-Bridging-Header.h` - Swift bridging header
|
||||||
|
|
||||||
|
### Keyboard Shortcuts
|
||||||
|
- `Cmd+T` / `Cmd+N` / `Ctrl+Shift+`` - New tab
|
||||||
|
- `Cmd+W` - Close tab
|
||||||
|
- `Cmd+Shift+]` / `Ctrl+Tab` - Next tab
|
||||||
|
- `Cmd+Shift+[` / `Ctrl+Shift+Tab` - Previous tab
|
||||||
|
- `Cmd+1-9` - Jump to tab by number
|
||||||
|
|
||||||
|
### Config
|
||||||
|
Reads user's Ghostty config from:
|
||||||
|
`~/Library/Application Support/com.mitchellh.ghostty/config`
|
||||||
1
GhosttyTabs-Bridging-Header.h
Normal file
1
GhosttyTabs-Bridging-Header.h
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
#import "ghostty.h"
|
||||||
317
GhosttyTabs.xcodeproj/project.pbxproj
Normal file
317
GhosttyTabs.xcodeproj/project.pbxproj
Normal file
|
|
@ -0,0 +1,317 @@
|
||||||
|
// !$*UTF8*$!
|
||||||
|
{
|
||||||
|
archiveVersion = 1;
|
||||||
|
classes = {
|
||||||
|
};
|
||||||
|
objectVersion = 56;
|
||||||
|
objects = {
|
||||||
|
|
||||||
|
/* Begin PBXBuildFile section */
|
||||||
|
A5001001 /* GhosttyTabsApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001011 /* GhosttyTabsApp.swift */; };
|
||||||
|
A5001002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001012 /* ContentView.swift */; };
|
||||||
|
A5001003 /* TabManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001013 /* TabManager.swift */; };
|
||||||
|
A5001004 /* GhosttyConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001014 /* GhosttyConfig.swift */; };
|
||||||
|
A5001005 /* GhosttyTerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001015 /* GhosttyTerminalView.swift */; };
|
||||||
|
A5001006 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5001016 /* GhosttyKit.xcframework */; };
|
||||||
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
|
/* Begin PBXCopyFilesBuildPhase section */
|
||||||
|
A5001020 /* Embed Frameworks */ = {
|
||||||
|
isa = PBXCopyFilesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
dstPath = "";
|
||||||
|
dstSubfolderSpec = 10;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
name = "Embed Frameworks";
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXFileReference section */
|
||||||
|
A5001000 /* GhosttyTabs.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = GhosttyTabs.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
A5001011 /* GhosttyTabsApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTabsApp.swift; sourceTree = "<group>"; };
|
||||||
|
A5001012 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||||
|
A5001013 /* TabManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManager.swift; sourceTree = "<group>"; };
|
||||||
|
A5001014 /* GhosttyConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfig.swift; sourceTree = "<group>"; };
|
||||||
|
A5001015 /* GhosttyTerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalView.swift; sourceTree = "<group>"; };
|
||||||
|
A5001016 /* GhosttyKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = GhosttyKit.xcframework; sourceTree = "<group>"; };
|
||||||
|
A5001017 /* ghostty.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ghostty.h; sourceTree = "<group>"; };
|
||||||
|
A5001018 /* GhosttyTabs-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "GhosttyTabs-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||||
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
A5001030 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
A5001006 /* GhosttyKit.xcframework in Frameworks */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXGroup section */
|
||||||
|
A5001040 = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
A5001041 /* Sources */,
|
||||||
|
A5001016 /* GhosttyKit.xcframework */,
|
||||||
|
A5001017 /* ghostty.h */,
|
||||||
|
A5001018 /* GhosttyTabs-Bridging-Header.h */,
|
||||||
|
A5001042 /* Products */,
|
||||||
|
);
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
A5001041 /* Sources */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
A5001011 /* GhosttyTabsApp.swift */,
|
||||||
|
A5001012 /* ContentView.swift */,
|
||||||
|
A5001013 /* TabManager.swift */,
|
||||||
|
A5001014 /* GhosttyConfig.swift */,
|
||||||
|
A5001015 /* GhosttyTerminalView.swift */,
|
||||||
|
);
|
||||||
|
path = Sources;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
A5001042 /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
A5001000 /* GhosttyTabs.app */,
|
||||||
|
);
|
||||||
|
name = Products;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXGroup section */
|
||||||
|
|
||||||
|
/* Begin PBXNativeTarget section */
|
||||||
|
A5001050 /* GhosttyTabs */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = A5001060 /* Build configuration list for PBXNativeTarget "GhosttyTabs" */;
|
||||||
|
buildPhases = (
|
||||||
|
A5001051 /* Sources */,
|
||||||
|
A5001030 /* Frameworks */,
|
||||||
|
A5001020 /* Embed Frameworks */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
name = GhosttyTabs;
|
||||||
|
productName = GhosttyTabs;
|
||||||
|
productReference = A5001000 /* GhosttyTabs.app */;
|
||||||
|
productType = "com.apple.product-type.application";
|
||||||
|
};
|
||||||
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
|
/* Begin PBXProject section */
|
||||||
|
A5001070 /* Project object */ = {
|
||||||
|
isa = PBXProject;
|
||||||
|
attributes = {
|
||||||
|
BuildIndependentTargetsInParallel = 1;
|
||||||
|
LastSwiftUpdateCheck = 1500;
|
||||||
|
LastUpgradeCheck = 1500;
|
||||||
|
};
|
||||||
|
buildConfigurationList = A5001071 /* Build configuration list for PBXProject "GhosttyTabs" */;
|
||||||
|
compatibilityVersion = "Xcode 14.0";
|
||||||
|
developmentRegion = en;
|
||||||
|
hasScannedForEncodings = 0;
|
||||||
|
knownRegions = (
|
||||||
|
en,
|
||||||
|
Base,
|
||||||
|
);
|
||||||
|
mainGroup = A5001040;
|
||||||
|
productRefGroup = A5001042 /* Products */;
|
||||||
|
projectDirPath = "";
|
||||||
|
projectRoot = "";
|
||||||
|
targets = (
|
||||||
|
A5001050 /* GhosttyTabs */,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
/* End PBXProject section */
|
||||||
|
|
||||||
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
A5001051 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
A5001001 /* GhosttyTabsApp.swift in Sources */,
|
||||||
|
A5001002 /* ContentView.swift in Sources */,
|
||||||
|
A5001003 /* TabManager.swift in Sources */,
|
||||||
|
A5001004 /* GhosttyConfig.swift in Sources */,
|
||||||
|
A5001005 /* GhosttyTerminalView.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin XCBuildConfiguration section */
|
||||||
|
A5001080 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_TESTABILITY = YES;
|
||||||
|
GCC_DYNAMIC_NO_PIC = NO;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_OPTIMIZATION_LEVEL = 0;
|
||||||
|
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||||
|
"DEBUG=1",
|
||||||
|
"$(inherited)",
|
||||||
|
);
|
||||||
|
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 = 13.0;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
|
SDKROOT = macosx;
|
||||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
A5001081 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
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 = 13.0;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
SDKROOT = macosx;
|
||||||
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
A5001082 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = "";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = "";
|
||||||
|
ENABLE_HARDENED_RUNTIME = NO;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
|
||||||
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
|
INFOPLIST_KEY_NSMainStoryboardFile = "";
|
||||||
|
INFOPLIST_KEY_NSPrincipalClass = NSApplication;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/../Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
OTHER_LDFLAGS = (
|
||||||
|
"-lc++",
|
||||||
|
"-framework",
|
||||||
|
Metal,
|
||||||
|
"-framework",
|
||||||
|
QuartzCore,
|
||||||
|
"-framework",
|
||||||
|
IOSurface,
|
||||||
|
"-framework",
|
||||||
|
UniformTypeIdentifiers,
|
||||||
|
"-framework",
|
||||||
|
Carbon,
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.ghosttytabs.app;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_OBJC_BRIDGING_HEADER = "GhosttyTabs-Bridging-Header.h";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
A5001083 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = "";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = "";
|
||||||
|
ENABLE_HARDENED_RUNTIME = NO;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
|
||||||
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
|
INFOPLIST_KEY_NSMainStoryboardFile = "";
|
||||||
|
INFOPLIST_KEY_NSPrincipalClass = NSApplication;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/../Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
OTHER_LDFLAGS = (
|
||||||
|
"-lc++",
|
||||||
|
"-framework",
|
||||||
|
Metal,
|
||||||
|
"-framework",
|
||||||
|
QuartzCore,
|
||||||
|
"-framework",
|
||||||
|
IOSurface,
|
||||||
|
"-framework",
|
||||||
|
UniformTypeIdentifiers,
|
||||||
|
"-framework",
|
||||||
|
Carbon,
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.ghosttytabs.app;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_OBJC_BRIDGING_HEADER = "GhosttyTabs-Bridging-Header.h";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
|
/* Begin XCConfigurationList section */
|
||||||
|
A5001060 /* Build configuration list for PBXNativeTarget "GhosttyTabs" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
A5001082 /* Debug */,
|
||||||
|
A5001083 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
A5001071 /* Build configuration list for PBXProject "GhosttyTabs" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
A5001080 /* Debug */,
|
||||||
|
A5001081 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
/* End XCConfigurationList section */
|
||||||
|
};
|
||||||
|
rootObject = A5001070 /* Project object */;
|
||||||
|
}
|
||||||
23
Package.resolved
Normal file
23
Package.resolved
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
"pins" : [
|
||||||
|
{
|
||||||
|
"identity" : "swift-argument-parser",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-argument-parser",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "c5d11a805e765f52ba34ec7284bd4fcd6ba68615",
|
||||||
|
"version" : "1.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swiftterm",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/migueldeicaza/SwiftTerm.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "0b8d99bd19b694df44e1ccaa3891309719d34330",
|
||||||
|
"version" : "1.5.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"version" : 2
|
||||||
|
}
|
||||||
22
Package.swift
Normal file
22
Package.swift
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
// swift-tools-version:5.9
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "GhosttyTabs",
|
||||||
|
platforms: [
|
||||||
|
.macOS(.v13)
|
||||||
|
],
|
||||||
|
products: [
|
||||||
|
.executable(name: "GhosttyTabs", targets: ["GhosttyTabs"])
|
||||||
|
],
|
||||||
|
dependencies: [
|
||||||
|
.package(url: "https://github.com/migueldeicaza/SwiftTerm.git", from: "1.2.0")
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
.executableTarget(
|
||||||
|
name: "GhosttyTabs",
|
||||||
|
dependencies: ["SwiftTerm"],
|
||||||
|
path: "Sources"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
120
Sources/ContentView.swift
Normal file
120
Sources/ContentView.swift
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ContentView: View {
|
||||||
|
@EnvironmentObject var tabManager: TabManager
|
||||||
|
@State private var sidebarWidth: CGFloat = 200
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
// Vertical Tabs Sidebar
|
||||||
|
VerticalTabsSidebar(sidebarWidth: sidebarWidth)
|
||||||
|
.frame(width: sidebarWidth)
|
||||||
|
|
||||||
|
// Divider
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color(nsColor: .separatorColor))
|
||||||
|
.frame(width: 1)
|
||||||
|
|
||||||
|
// Terminal Content
|
||||||
|
if let selectedId = tabManager.selectedTabId,
|
||||||
|
let tab = tabManager.tabs.first(where: { $0.id == selectedId }) {
|
||||||
|
GhosttyTerminalView()
|
||||||
|
.id(tab.id)
|
||||||
|
} else {
|
||||||
|
Color(nsColor: .windowBackgroundColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(minWidth: 800, minHeight: 600)
|
||||||
|
.background(Color(nsColor: .windowBackgroundColor))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct VerticalTabsSidebar: View {
|
||||||
|
@EnvironmentObject var tabManager: TabManager
|
||||||
|
let sidebarWidth: CGFloat
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Header with title
|
||||||
|
HStack {
|
||||||
|
Text("Tabs")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Spacer()
|
||||||
|
Button(action: { tabManager.addTab() }) {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
.font(.system(size: 12, weight: .medium))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Tab List
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(spacing: 2) {
|
||||||
|
ForEach(tabManager.tabs) { tab in
|
||||||
|
TabItemView(tab: tab)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.background(Color(nsColor: .controlBackgroundColor))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TabItemView: View {
|
||||||
|
@EnvironmentObject var tabManager: TabManager
|
||||||
|
@ObservedObject var tab: Tab
|
||||||
|
@State private var isHovering = false
|
||||||
|
|
||||||
|
var isSelected: Bool {
|
||||||
|
tabManager.selectedTabId == tab.id
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "terminal")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundColor(isSelected ? .white : .secondary)
|
||||||
|
|
||||||
|
Text(tab.title)
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundColor(isSelected ? .white : .primary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if isHovering || isSelected {
|
||||||
|
Button(action: { tabManager.closeTab(tab) }) {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.font(.system(size: 9, weight: .medium))
|
||||||
|
.foregroundColor(isSelected ? .white.opacity(0.7) : .secondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.opacity(tabManager.tabs.count > 1 ? 1 : 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.fill(isSelected ? Color.accentColor : (isHovering ? Color(nsColor: .controlBackgroundColor).opacity(0.5) : Color.clear))
|
||||||
|
)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
tabManager.selectTab(tab)
|
||||||
|
}
|
||||||
|
.onHover { hovering in
|
||||||
|
isHovering = hovering
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
143
Sources/GhosttyConfig.swift
Normal file
143
Sources/GhosttyConfig.swift
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
import Foundation
|
||||||
|
import AppKit
|
||||||
|
|
||||||
|
struct GhosttyConfig {
|
||||||
|
var fontFamily: String = "Menlo"
|
||||||
|
var fontSize: CGFloat = 12
|
||||||
|
var theme: String?
|
||||||
|
var workingDirectory: String?
|
||||||
|
var scrollbackLimit: Int = 10000
|
||||||
|
|
||||||
|
// Colors (from theme or config)
|
||||||
|
var backgroundColor: NSColor = NSColor(hex: "#272822")!
|
||||||
|
var foregroundColor: NSColor = NSColor(hex: "#fdfff1")!
|
||||||
|
var cursorColor: NSColor = NSColor(hex: "#c0c1b5")!
|
||||||
|
var cursorTextColor: NSColor = NSColor(hex: "#8d8e82")!
|
||||||
|
var selectionBackground: NSColor = NSColor(hex: "#57584f")!
|
||||||
|
var selectionForeground: NSColor = NSColor(hex: "#fdfff1")!
|
||||||
|
|
||||||
|
// Palette colors (0-15)
|
||||||
|
var palette: [Int: NSColor] = [:]
|
||||||
|
|
||||||
|
static func load() -> GhosttyConfig {
|
||||||
|
var config = GhosttyConfig()
|
||||||
|
|
||||||
|
// Load user config
|
||||||
|
let configPath = NSString(string: "~/Library/Application Support/com.mitchellh.ghostty/config").expandingTildeInPath
|
||||||
|
if let contents = try? String(contentsOfFile: configPath, encoding: .utf8) {
|
||||||
|
config.parse(contents)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load theme if specified
|
||||||
|
if let themeName = config.theme {
|
||||||
|
config.loadTheme(themeName)
|
||||||
|
}
|
||||||
|
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func parse(_ contents: String) {
|
||||||
|
let lines = contents.components(separatedBy: .newlines)
|
||||||
|
for line in lines {
|
||||||
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||||
|
if trimmed.isEmpty || trimmed.hasPrefix("#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let parts = trimmed.split(separator: "=", maxSplits: 1)
|
||||||
|
if parts.count == 2 {
|
||||||
|
let key = parts[0].trimmingCharacters(in: .whitespaces)
|
||||||
|
let value = parts[1].trimmingCharacters(in: .whitespaces).trimmingCharacters(in: CharacterSet(charactersIn: "\""))
|
||||||
|
|
||||||
|
switch key {
|
||||||
|
case "font-family":
|
||||||
|
fontFamily = value
|
||||||
|
case "font-size":
|
||||||
|
if let size = Double(value) {
|
||||||
|
fontSize = CGFloat(size)
|
||||||
|
}
|
||||||
|
case "theme":
|
||||||
|
theme = value
|
||||||
|
case "working-directory":
|
||||||
|
workingDirectory = value
|
||||||
|
case "scrollback-limit":
|
||||||
|
if let limit = Int(value) {
|
||||||
|
scrollbackLimit = limit
|
||||||
|
}
|
||||||
|
case "background":
|
||||||
|
if let color = NSColor(hex: value) {
|
||||||
|
backgroundColor = color
|
||||||
|
}
|
||||||
|
case "foreground":
|
||||||
|
if let color = NSColor(hex: value) {
|
||||||
|
foregroundColor = color
|
||||||
|
}
|
||||||
|
case "cursor-color":
|
||||||
|
if let color = NSColor(hex: value) {
|
||||||
|
cursorColor = color
|
||||||
|
}
|
||||||
|
case "cursor-text":
|
||||||
|
if let color = NSColor(hex: value) {
|
||||||
|
cursorTextColor = color
|
||||||
|
}
|
||||||
|
case "selection-background":
|
||||||
|
if let color = NSColor(hex: value) {
|
||||||
|
selectionBackground = color
|
||||||
|
}
|
||||||
|
case "selection-foreground":
|
||||||
|
if let color = NSColor(hex: value) {
|
||||||
|
selectionForeground = color
|
||||||
|
}
|
||||||
|
case "palette":
|
||||||
|
// Parse palette entries like "0=#272822"
|
||||||
|
let paletteParts = value.split(separator: "=", maxSplits: 1)
|
||||||
|
if paletteParts.count == 2,
|
||||||
|
let index = Int(paletteParts[0]),
|
||||||
|
let color = NSColor(hex: String(paletteParts[1])) {
|
||||||
|
palette[index] = color
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func loadTheme(_ name: String) {
|
||||||
|
// Try to load from Ghostty app resources
|
||||||
|
let themePaths = [
|
||||||
|
"/Applications/Ghostty.app/Contents/Resources/ghostty/themes/\(name)",
|
||||||
|
NSString(string: "~/.config/ghostty/themes/\(name)").expandingTildeInPath
|
||||||
|
]
|
||||||
|
|
||||||
|
for path in themePaths {
|
||||||
|
if let contents = try? String(contentsOfFile: path, encoding: .utf8) {
|
||||||
|
parse(contents)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension NSColor {
|
||||||
|
convenience init?(hex: String) {
|
||||||
|
var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "")
|
||||||
|
|
||||||
|
var rgb: UInt64 = 0
|
||||||
|
guard Scanner(string: hexSanitized).scanHexInt64(&rgb) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let r, g, b: CGFloat
|
||||||
|
if hexSanitized.count == 6 {
|
||||||
|
r = CGFloat((rgb & 0xFF0000) >> 16) / 255.0
|
||||||
|
g = CGFloat((rgb & 0x00FF00) >> 8) / 255.0
|
||||||
|
b = CGFloat(rgb & 0x0000FF) / 255.0
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
self.init(red: r, green: g, blue: b, alpha: 1.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
74
Sources/GhosttyTabsApp.swift
Normal file
74
Sources/GhosttyTabsApp.swift
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct GhosttyTabsApp: App {
|
||||||
|
@StateObject private var tabManager = TabManager()
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
ContentView()
|
||||||
|
.environmentObject(tabManager)
|
||||||
|
}
|
||||||
|
.windowStyle(.hiddenTitleBar)
|
||||||
|
.commands {
|
||||||
|
// New tab commands
|
||||||
|
CommandGroup(replacing: .newItem) {
|
||||||
|
Button("New Tab") {
|
||||||
|
tabManager.addTab()
|
||||||
|
}
|
||||||
|
.keyboardShortcut("t", modifiers: .command)
|
||||||
|
|
||||||
|
Button("New Tab") {
|
||||||
|
tabManager.addTab()
|
||||||
|
}
|
||||||
|
.keyboardShortcut("n", modifiers: .command)
|
||||||
|
|
||||||
|
Button("New Tab") {
|
||||||
|
tabManager.addTab()
|
||||||
|
}
|
||||||
|
.keyboardShortcut("`", modifiers: [.control, .shift])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close tab
|
||||||
|
CommandGroup(after: .newItem) {
|
||||||
|
Button("Close Tab") {
|
||||||
|
tabManager.closeCurrentTab()
|
||||||
|
}
|
||||||
|
.keyboardShortcut("w", modifiers: .command)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab navigation
|
||||||
|
CommandGroup(after: .toolbar) {
|
||||||
|
Button("Next Tab") {
|
||||||
|
tabManager.selectNextTab()
|
||||||
|
}
|
||||||
|
.keyboardShortcut("]", modifiers: [.command, .shift])
|
||||||
|
|
||||||
|
Button("Previous Tab") {
|
||||||
|
tabManager.selectPreviousTab()
|
||||||
|
}
|
||||||
|
.keyboardShortcut("[", modifiers: [.command, .shift])
|
||||||
|
|
||||||
|
Button("Next Tab") {
|
||||||
|
tabManager.selectNextTab()
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.tab, modifiers: .control)
|
||||||
|
|
||||||
|
Button("Previous Tab") {
|
||||||
|
tabManager.selectPreviousTab()
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.tab, modifiers: [.control, .shift])
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Cmd+1 through Cmd+9 for tab selection
|
||||||
|
ForEach(1...9, id: \.self) { number in
|
||||||
|
Button("Tab \(number)") {
|
||||||
|
tabManager.selectTab(at: number - 1)
|
||||||
|
}
|
||||||
|
.keyboardShortcut(KeyEquivalent(Character("\(number)")), modifiers: .command)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
383
Sources/GhosttyTerminalView.swift
Normal file
383
Sources/GhosttyTerminalView.swift
Normal file
|
|
@ -0,0 +1,383 @@
|
||||||
|
import SwiftUI
|
||||||
|
import AppKit
|
||||||
|
import Metal
|
||||||
|
import QuartzCore
|
||||||
|
|
||||||
|
// Minimal Ghostty wrapper for terminal rendering
|
||||||
|
// This uses libghostty (GhosttyKit.xcframework) for actual terminal emulation
|
||||||
|
|
||||||
|
// MARK: - Ghostty App Singleton
|
||||||
|
|
||||||
|
class GhosttyApp {
|
||||||
|
static let shared = GhosttyApp()
|
||||||
|
|
||||||
|
private(set) var app: ghostty_app_t?
|
||||||
|
private(set) var config: ghostty_config_t?
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
initializeGhostty()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func initializeGhostty() {
|
||||||
|
// Initialize Ghostty library first
|
||||||
|
let result = ghostty_init(0, nil)
|
||||||
|
if result != GHOSTTY_SUCCESS {
|
||||||
|
print("Failed to initialize ghostty: \(result)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load config
|
||||||
|
config = ghostty_config_new()
|
||||||
|
guard let config = config else {
|
||||||
|
print("Failed to create ghostty config")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load default config
|
||||||
|
ghostty_config_load_default_files(config)
|
||||||
|
ghostty_config_finalize(config)
|
||||||
|
|
||||||
|
// Create runtime config with callbacks
|
||||||
|
var runtimeConfig = ghostty_runtime_config_s()
|
||||||
|
runtimeConfig.userdata = Unmanaged.passUnretained(self).toOpaque()
|
||||||
|
runtimeConfig.supports_selection_clipboard = true
|
||||||
|
runtimeConfig.wakeup_cb = { userdata in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
// Wakeup - trigger redraw if needed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runtimeConfig.action_cb = { app, target, action in
|
||||||
|
// Handle actions
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
runtimeConfig.read_clipboard_cb = { userdata, location, state in
|
||||||
|
// Read clipboard
|
||||||
|
}
|
||||||
|
runtimeConfig.write_clipboard_cb = { userdata, location, content, len, confirm in
|
||||||
|
// Write clipboard
|
||||||
|
if let content = content {
|
||||||
|
let data = Data(bytes: content, count: Int(len))
|
||||||
|
if let string = String(data: data, encoding: .utf8) {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
NSPasteboard.general.clearContents()
|
||||||
|
NSPasteboard.general.setString(string, forType: .string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runtimeConfig.close_surface_cb = { userdata, processAlive in
|
||||||
|
// Surface closed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create app
|
||||||
|
app = ghostty_app_new(&runtimeConfig, config)
|
||||||
|
if app == nil {
|
||||||
|
print("Failed to create ghostty app")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func tick() {
|
||||||
|
guard let app = app else { return }
|
||||||
|
ghostty_app_tick(app)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Ghostty Surface View
|
||||||
|
|
||||||
|
class GhosttyNSView: NSView {
|
||||||
|
private var surface: ghostty_surface_t?
|
||||||
|
private var displayLink: CVDisplayLink?
|
||||||
|
private var metalDevice: MTLDevice?
|
||||||
|
private var surfaceCreated = false
|
||||||
|
|
||||||
|
override func makeBackingLayer() -> CALayer {
|
||||||
|
let metalLayer = CAMetalLayer()
|
||||||
|
metalLayer.device = MTLCreateSystemDefaultDevice()
|
||||||
|
metalLayer.pixelFormat = .bgra8Unorm
|
||||||
|
metalLayer.framebufferOnly = true
|
||||||
|
metalLayer.isOpaque = true
|
||||||
|
metalLayer.contentsScale = NSScreen.main?.backingScaleFactor ?? 2.0
|
||||||
|
self.metalDevice = metalLayer.device
|
||||||
|
return metalLayer
|
||||||
|
}
|
||||||
|
|
||||||
|
override init(frame frameRect: NSRect) {
|
||||||
|
super.init(frame: frameRect)
|
||||||
|
setup()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
super.init(coder: coder)
|
||||||
|
setup()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setup() {
|
||||||
|
wantsLayer = true
|
||||||
|
layerContentsRedrawPolicy = .duringViewResize
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createSurfaceIfNeeded() {
|
||||||
|
// Only create once and when we have a valid size
|
||||||
|
guard !surfaceCreated else { return }
|
||||||
|
guard bounds.width > 0 && bounds.height > 0 else { return }
|
||||||
|
guard window != nil else { return }
|
||||||
|
|
||||||
|
surfaceCreated = true
|
||||||
|
createSurface()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createSurface() {
|
||||||
|
guard let app = GhosttyApp.shared.app else {
|
||||||
|
print("Ghostty app not initialized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let scale = window?.screen?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 2.0
|
||||||
|
|
||||||
|
// Update Metal layer with initial size
|
||||||
|
if let metalLayer = layer as? CAMetalLayer {
|
||||||
|
metalLayer.contentsScale = scale
|
||||||
|
metalLayer.drawableSize = CGSize(
|
||||||
|
width: bounds.width * scale,
|
||||||
|
height: bounds.height * scale
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var surfaceConfig = ghostty_surface_config_new()
|
||||||
|
surfaceConfig.platform_tag = GHOSTTY_PLATFORM_MACOS
|
||||||
|
|
||||||
|
// Pass this view to ghostty
|
||||||
|
surfaceConfig.platform.macos.nsview = Unmanaged.passUnretained(self).toOpaque()
|
||||||
|
|
||||||
|
// Set scale factor
|
||||||
|
surfaceConfig.scale_factor = scale
|
||||||
|
|
||||||
|
surfaceConfig.context = GHOSTTY_SURFACE_CONTEXT_WINDOW
|
||||||
|
|
||||||
|
// Create the surface
|
||||||
|
surface = ghostty_surface_new(app, &surfaceConfig)
|
||||||
|
|
||||||
|
if surface == nil {
|
||||||
|
print("Failed to create ghostty surface")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set initial size immediately after creation
|
||||||
|
ghostty_surface_set_size(
|
||||||
|
surface,
|
||||||
|
UInt32(bounds.width * scale),
|
||||||
|
UInt32(bounds.height * scale)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Setup display link for rendering
|
||||||
|
setupDisplayLink()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupDisplayLink() {
|
||||||
|
var link: CVDisplayLink?
|
||||||
|
CVDisplayLinkCreateWithActiveCGDisplays(&link)
|
||||||
|
guard let displayLink = link else { return }
|
||||||
|
|
||||||
|
self.displayLink = displayLink
|
||||||
|
|
||||||
|
let callback: CVDisplayLinkOutputCallback = { displayLink, inNow, inOutputTime, flagsIn, flagsOut, displayLinkContext -> CVReturn in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
GhosttyApp.shared.tick()
|
||||||
|
}
|
||||||
|
return kCVReturnSuccess
|
||||||
|
}
|
||||||
|
|
||||||
|
CVDisplayLinkSetOutputCallback(displayLink, callback, nil)
|
||||||
|
CVDisplayLinkStart(displayLink)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidMoveToWindow() {
|
||||||
|
super.viewDidMoveToWindow()
|
||||||
|
if window != nil {
|
||||||
|
createSurfaceIfNeeded()
|
||||||
|
updateSurfaceSize()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setFrameSize(_ newSize: NSSize) {
|
||||||
|
super.setFrameSize(newSize)
|
||||||
|
createSurfaceIfNeeded()
|
||||||
|
updateSurfaceSize()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func layout() {
|
||||||
|
super.layout()
|
||||||
|
createSurfaceIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateSurfaceSize() {
|
||||||
|
guard let surface = surface else { return }
|
||||||
|
let scale = window?.screen?.backingScaleFactor ?? 2.0
|
||||||
|
|
||||||
|
// Update Metal layer
|
||||||
|
if let metalLayer = layer as? CAMetalLayer {
|
||||||
|
metalLayer.contentsScale = scale
|
||||||
|
metalLayer.drawableSize = CGSize(
|
||||||
|
width: bounds.width * scale,
|
||||||
|
height: bounds.height * scale
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ghostty_surface_set_size(
|
||||||
|
surface,
|
||||||
|
UInt32(bounds.width * scale),
|
||||||
|
UInt32(bounds.height * scale)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Input Handling
|
||||||
|
|
||||||
|
override var acceptsFirstResponder: Bool { true }
|
||||||
|
|
||||||
|
private func ghosttyCharacters(from event: NSEvent) -> String? {
|
||||||
|
guard let chars = event.characters, !chars.isEmpty else { return nil }
|
||||||
|
for scalar in chars.unicodeScalars where scalar.value < 0x20 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return chars
|
||||||
|
}
|
||||||
|
|
||||||
|
override func keyDown(with event: NSEvent) {
|
||||||
|
guard let surface = surface else {
|
||||||
|
super.keyDown(with: event)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
interpretKeyEvents([event])
|
||||||
|
|
||||||
|
var keyEvent = ghostty_input_key_s()
|
||||||
|
keyEvent.action = GHOSTTY_ACTION_PRESS
|
||||||
|
keyEvent.keycode = UInt32(event.keyCode)
|
||||||
|
keyEvent.mods = modsFromEvent(event)
|
||||||
|
keyEvent.consumed_mods = GHOSTTY_MODS_NONE
|
||||||
|
keyEvent.composing = false
|
||||||
|
|
||||||
|
if let text = ghosttyCharacters(from: event) {
|
||||||
|
text.withCString { ptr in
|
||||||
|
keyEvent.text = ptr
|
||||||
|
_ = ghostty_surface_key(surface, keyEvent)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
keyEvent.text = nil
|
||||||
|
_ = ghostty_surface_key(surface, keyEvent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func keyUp(with event: NSEvent) {
|
||||||
|
guard let surface = surface else {
|
||||||
|
super.keyUp(with: event)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var keyEvent = ghostty_input_key_s()
|
||||||
|
keyEvent.action = GHOSTTY_ACTION_RELEASE
|
||||||
|
keyEvent.keycode = UInt32(event.keyCode)
|
||||||
|
keyEvent.mods = modsFromEvent(event)
|
||||||
|
keyEvent.consumed_mods = GHOSTTY_MODS_NONE
|
||||||
|
keyEvent.text = nil
|
||||||
|
keyEvent.composing = false
|
||||||
|
_ = ghostty_surface_key(surface, keyEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func flagsChanged(with event: NSEvent) {
|
||||||
|
guard let surface = surface else {
|
||||||
|
super.flagsChanged(with: event)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var keyEvent = ghostty_input_key_s()
|
||||||
|
keyEvent.action = GHOSTTY_ACTION_PRESS
|
||||||
|
keyEvent.keycode = UInt32(event.keyCode)
|
||||||
|
keyEvent.mods = modsFromEvent(event)
|
||||||
|
keyEvent.consumed_mods = GHOSTTY_MODS_NONE
|
||||||
|
keyEvent.text = nil
|
||||||
|
keyEvent.composing = false
|
||||||
|
_ = ghostty_surface_key(surface, keyEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func modsFromEvent(_ event: NSEvent) -> ghostty_input_mods_e {
|
||||||
|
var mods = GHOSTTY_MODS_NONE.rawValue
|
||||||
|
if event.modifierFlags.contains(.shift) { mods |= GHOSTTY_MODS_SHIFT.rawValue }
|
||||||
|
if event.modifierFlags.contains(.control) { mods |= GHOSTTY_MODS_CTRL.rawValue }
|
||||||
|
if event.modifierFlags.contains(.option) { mods |= GHOSTTY_MODS_ALT.rawValue }
|
||||||
|
if event.modifierFlags.contains(.command) { mods |= GHOSTTY_MODS_SUPER.rawValue }
|
||||||
|
return ghostty_input_mods_e(rawValue: mods)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Mouse Handling
|
||||||
|
|
||||||
|
override func mouseDown(with event: NSEvent) {
|
||||||
|
window?.makeFirstResponder(self)
|
||||||
|
guard let surface = surface else { return }
|
||||||
|
let point = convert(event.locationInWindow, from: nil)
|
||||||
|
ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event))
|
||||||
|
_ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, modsFromEvent(event))
|
||||||
|
}
|
||||||
|
|
||||||
|
override func mouseUp(with event: NSEvent) {
|
||||||
|
guard let surface = surface else { return }
|
||||||
|
_ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, modsFromEvent(event))
|
||||||
|
}
|
||||||
|
|
||||||
|
override func mouseMoved(with event: NSEvent) {
|
||||||
|
guard let surface = surface else { return }
|
||||||
|
let point = convert(event.locationInWindow, from: nil)
|
||||||
|
ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event))
|
||||||
|
}
|
||||||
|
|
||||||
|
override func mouseDragged(with event: NSEvent) {
|
||||||
|
guard let surface = surface else { return }
|
||||||
|
let point = convert(event.locationInWindow, from: nil)
|
||||||
|
ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event))
|
||||||
|
}
|
||||||
|
|
||||||
|
override func scrollWheel(with event: NSEvent) {
|
||||||
|
guard let surface = surface else { return }
|
||||||
|
var mods: Int32 = 0
|
||||||
|
if event.modifierFlags.contains(.shift) { mods |= Int32(GHOSTTY_MODS_SHIFT.rawValue) }
|
||||||
|
if event.modifierFlags.contains(.control) { mods |= Int32(GHOSTTY_MODS_CTRL.rawValue) }
|
||||||
|
if event.modifierFlags.contains(.option) { mods |= Int32(GHOSTTY_MODS_ALT.rawValue) }
|
||||||
|
if event.modifierFlags.contains(.command) { mods |= Int32(GHOSTTY_MODS_SUPER.rawValue) }
|
||||||
|
|
||||||
|
ghostty_surface_mouse_scroll(
|
||||||
|
surface,
|
||||||
|
event.scrollingDeltaX,
|
||||||
|
event.scrollingDeltaY,
|
||||||
|
ghostty_input_scroll_mods_t(mods)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
if let displayLink = displayLink {
|
||||||
|
CVDisplayLinkStop(displayLink)
|
||||||
|
}
|
||||||
|
if let surface = surface {
|
||||||
|
ghostty_surface_free(surface)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - SwiftUI Wrapper
|
||||||
|
|
||||||
|
struct GhosttyTerminalView: NSViewRepresentable {
|
||||||
|
func makeNSView(context: Context) -> GhosttyNSView {
|
||||||
|
let view = GhosttyNSView(frame: .zero)
|
||||||
|
// Focus after view is in window
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||||
|
view.window?.makeFirstResponder(view)
|
||||||
|
}
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateNSView(_ nsView: GhosttyNSView, context: Context) {
|
||||||
|
// Focus on tab switch
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
nsView.window?.makeFirstResponder(nsView)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
73
Sources/TabManager.swift
Normal file
73
Sources/TabManager.swift
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import SwiftUI
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
class Tab: Identifiable, ObservableObject {
|
||||||
|
let id = UUID()
|
||||||
|
@Published var title: String
|
||||||
|
@Published var currentDirectory: String
|
||||||
|
|
||||||
|
init(title: String = "Terminal") {
|
||||||
|
self.title = title
|
||||||
|
self.currentDirectory = FileManager.default.homeDirectoryForCurrentUser.path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TabManager: ObservableObject {
|
||||||
|
@Published var tabs: [Tab] = []
|
||||||
|
@Published var selectedTabId: UUID?
|
||||||
|
|
||||||
|
init() {
|
||||||
|
addTab()
|
||||||
|
}
|
||||||
|
|
||||||
|
func addTab() {
|
||||||
|
let newTab = Tab(title: "Terminal \(tabs.count + 1)")
|
||||||
|
tabs.append(newTab)
|
||||||
|
selectedTabId = newTab.id
|
||||||
|
}
|
||||||
|
|
||||||
|
func closeTab(_ tab: Tab) {
|
||||||
|
guard tabs.count > 1 else { return }
|
||||||
|
|
||||||
|
if let index = tabs.firstIndex(where: { $0.id == tab.id }) {
|
||||||
|
tabs.remove(at: index)
|
||||||
|
|
||||||
|
if selectedTabId == tab.id {
|
||||||
|
if index > 0 {
|
||||||
|
selectedTabId = tabs[index - 1].id
|
||||||
|
} else {
|
||||||
|
selectedTabId = tabs.first?.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func closeCurrentTab() {
|
||||||
|
guard let selectedId = selectedTabId,
|
||||||
|
let tab = tabs.first(where: { $0.id == selectedId }) else { return }
|
||||||
|
closeTab(tab)
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectTab(_ tab: Tab) {
|
||||||
|
selectedTabId = tab.id
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectNextTab() {
|
||||||
|
guard let currentId = selectedTabId,
|
||||||
|
let currentIndex = tabs.firstIndex(where: { $0.id == currentId }) else { return }
|
||||||
|
let nextIndex = (currentIndex + 1) % tabs.count
|
||||||
|
selectedTabId = tabs[nextIndex].id
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectPreviousTab() {
|
||||||
|
guard let currentId = selectedTabId,
|
||||||
|
let currentIndex = tabs.firstIndex(where: { $0.id == currentId }) else { return }
|
||||||
|
let prevIndex = (currentIndex - 1 + tabs.count) % tabs.count
|
||||||
|
selectedTabId = tabs[prevIndex].id
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectTab(at index: Int) {
|
||||||
|
guard index >= 0 && index < tabs.count else { return }
|
||||||
|
selectedTabId = tabs[index].id
|
||||||
|
}
|
||||||
|
}
|
||||||
287
Sources/TerminalView.swift
Normal file
287
Sources/TerminalView.swift
Normal file
|
|
@ -0,0 +1,287 @@
|
||||||
|
import SwiftUI
|
||||||
|
import SwiftTerm
|
||||||
|
import AppKit
|
||||||
|
|
||||||
|
// Helper to create SwiftTerm Color from hex
|
||||||
|
extension SwiftTerm.Color {
|
||||||
|
convenience init(hex: String) {
|
||||||
|
var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "")
|
||||||
|
|
||||||
|
var rgb: UInt64 = 0
|
||||||
|
Scanner(string: hexSanitized).scanHexInt64(&rgb)
|
||||||
|
|
||||||
|
let r = UInt16((rgb & 0xFF0000) >> 16)
|
||||||
|
let g = UInt16((rgb & 0x00FF00) >> 8)
|
||||||
|
let b = UInt16(rgb & 0x0000FF)
|
||||||
|
|
||||||
|
// Convert 8-bit to 16-bit
|
||||||
|
self.init(red: r * 257, green: g * 257, blue: b * 257)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TerminalContainerView: View {
|
||||||
|
@ObservedObject var tab: Tab
|
||||||
|
let config: GhosttyConfig
|
||||||
|
|
||||||
|
init(tab: Tab, config: GhosttyConfig = GhosttyConfig.load()) {
|
||||||
|
self.tab = tab
|
||||||
|
self.config = config
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
SwiftTermView(tab: tab, config: config)
|
||||||
|
.background(Color(config.backgroundColor))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom wrapper to handle first responder and layout
|
||||||
|
class FocusableTerminalView: NSView {
|
||||||
|
var terminalView: LocalProcessTerminalView?
|
||||||
|
private var scroller: NSScroller?
|
||||||
|
private var fadeTimer: Timer?
|
||||||
|
private var scrollMonitor: Any?
|
||||||
|
private var lastScrollerValue: Double = 0
|
||||||
|
|
||||||
|
override var acceptsFirstResponder: Bool { true }
|
||||||
|
|
||||||
|
override func becomeFirstResponder() -> Bool {
|
||||||
|
if let tv = terminalView {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.window?.makeFirstResponder(tv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override func mouseDown(with event: NSEvent) {
|
||||||
|
window?.makeFirstResponder(terminalView)
|
||||||
|
super.mouseDown(with: event)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func layout() {
|
||||||
|
super.layout()
|
||||||
|
if let tv = terminalView, bounds.size.width > 0, bounds.size.height > 0 {
|
||||||
|
tv.setFrameSize(bounds.size)
|
||||||
|
setupScrollerTracking(in: tv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidMoveToWindow() {
|
||||||
|
super.viewDidMoveToWindow()
|
||||||
|
if window != nil, let tv = terminalView, bounds.size.width > 0 {
|
||||||
|
tv.setFrameSize(bounds.size)
|
||||||
|
setupScrollerTracking(in: tv)
|
||||||
|
setupScrollMonitor()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillMove(toWindow newWindow: NSWindow?) {
|
||||||
|
super.viewWillMove(toWindow: newWindow)
|
||||||
|
if newWindow == nil, let monitor = scrollMonitor {
|
||||||
|
NSEvent.removeMonitor(monitor)
|
||||||
|
scrollMonitor = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupScrollerTracking(in view: NSView) {
|
||||||
|
if scroller == nil {
|
||||||
|
for subview in view.subviews {
|
||||||
|
if let s = subview as? NSScroller {
|
||||||
|
scroller = s
|
||||||
|
s.alphaValue = 0 // Start hidden
|
||||||
|
lastScrollerValue = s.doubleValue
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupScrollMonitor() {
|
||||||
|
guard scrollMonitor == nil else { return }
|
||||||
|
|
||||||
|
// Monitor scroll wheel events
|
||||||
|
scrollMonitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { [weak self] event in
|
||||||
|
if let self = self,
|
||||||
|
let window = self.window,
|
||||||
|
event.window == window {
|
||||||
|
self.showScrollerTemporarily()
|
||||||
|
}
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func showScrollerTemporarily() {
|
||||||
|
guard let scroller = scroller else { return }
|
||||||
|
|
||||||
|
// Show scroller
|
||||||
|
NSAnimationContext.runAnimationGroup { context in
|
||||||
|
context.duration = 0.15
|
||||||
|
scroller.animator().alphaValue = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel existing timer
|
||||||
|
fadeTimer?.invalidate()
|
||||||
|
|
||||||
|
// Fade out after 1.5 seconds of no scrolling
|
||||||
|
fadeTimer = Timer.scheduledTimer(withTimeInterval: 1.5, repeats: false) { [weak self] _ in
|
||||||
|
NSAnimationContext.runAnimationGroup { context in
|
||||||
|
context.duration = 0.3
|
||||||
|
self?.scroller?.animator().alphaValue = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SwiftTermView: NSViewRepresentable {
|
||||||
|
@ObservedObject var tab: Tab
|
||||||
|
let config: GhosttyConfig
|
||||||
|
|
||||||
|
func makeNSView(context: Context) -> FocusableTerminalView {
|
||||||
|
let containerView = FocusableTerminalView()
|
||||||
|
containerView.wantsLayer = true
|
||||||
|
|
||||||
|
let terminalView = LocalProcessTerminalView(frame: CGRect(x: 0, y: 0, width: 800, height: 600))
|
||||||
|
|
||||||
|
// Use autoresizingMask instead of Auto Layout for SwiftTerm compatibility
|
||||||
|
terminalView.autoresizingMask = [.width, .height]
|
||||||
|
|
||||||
|
// Apply Ghostty config colors
|
||||||
|
terminalView.nativeForegroundColor = config.foregroundColor
|
||||||
|
terminalView.nativeBackgroundColor = config.backgroundColor
|
||||||
|
|
||||||
|
// Set cursor color to match Ghostty
|
||||||
|
terminalView.caretColor = config.cursorColor
|
||||||
|
terminalView.caretTextColor = config.cursorTextColor
|
||||||
|
|
||||||
|
// Set selection colors
|
||||||
|
terminalView.selectedTextBackgroundColor = config.selectionBackground
|
||||||
|
|
||||||
|
// Apply ANSI palette colors
|
||||||
|
applyPalette(to: terminalView, config: config)
|
||||||
|
|
||||||
|
// Configure font from config
|
||||||
|
if let font = NSFont(name: config.fontFamily, size: config.fontSize) {
|
||||||
|
terminalView.font = font
|
||||||
|
} else {
|
||||||
|
terminalView.font = NSFont.monospacedSystemFont(ofSize: config.fontSize, weight: .regular)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set terminal delegate (only processDelegate, not terminalDelegate which breaks input)
|
||||||
|
terminalView.processDelegate = context.coordinator
|
||||||
|
context.coordinator.terminalView = terminalView
|
||||||
|
context.coordinator.containerView = containerView
|
||||||
|
|
||||||
|
containerView.addSubview(terminalView)
|
||||||
|
containerView.terminalView = terminalView
|
||||||
|
|
||||||
|
// Get shell path
|
||||||
|
let shell = ProcessInfo.processInfo.environment["SHELL"] ?? "/bin/zsh"
|
||||||
|
|
||||||
|
// Determine working directory
|
||||||
|
let workingDir = config.workingDirectory ?? FileManager.default.homeDirectoryForCurrentUser.path
|
||||||
|
|
||||||
|
// Build environment with working directory
|
||||||
|
var env = ProcessInfo.processInfo.environment
|
||||||
|
env["PWD"] = workingDir
|
||||||
|
|
||||||
|
// Start the shell process
|
||||||
|
terminalView.startProcess(
|
||||||
|
executable: shell,
|
||||||
|
args: [],
|
||||||
|
environment: env.map { "\($0.key)=\($0.value)" },
|
||||||
|
execName: "-" + (shell as NSString).lastPathComponent
|
||||||
|
)
|
||||||
|
|
||||||
|
// Change to working directory
|
||||||
|
terminalView.feed(text: "cd \"\(workingDir)\" && clear\n")
|
||||||
|
|
||||||
|
|
||||||
|
// Make first responder after a delay to ensure window is ready
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||||
|
containerView.window?.makeFirstResponder(terminalView)
|
||||||
|
}
|
||||||
|
|
||||||
|
return containerView
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateNSView(_ nsView: FocusableTerminalView, context: Context) {
|
||||||
|
// When this view becomes visible (tab switch), make it first responder
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
if let terminalView = nsView.terminalView {
|
||||||
|
nsView.window?.makeFirstResponder(terminalView)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator {
|
||||||
|
Coordinator(tab: tab)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyPalette(to terminalView: LocalProcessTerminalView, config: GhosttyConfig) {
|
||||||
|
// SwiftTerm uses installColors to set the ANSI color palette
|
||||||
|
// Build the color array (16 ANSI colors)
|
||||||
|
|
||||||
|
// Default Monokai Classic palette hex values
|
||||||
|
let defaultPaletteHex: [String] = [
|
||||||
|
"#272822", // 0 - black
|
||||||
|
"#f92672", // 1 - red
|
||||||
|
"#a6e22e", // 2 - green
|
||||||
|
"#e6db74", // 3 - yellow
|
||||||
|
"#fd971f", // 4 - blue (orange in Monokai)
|
||||||
|
"#ae81ff", // 5 - magenta
|
||||||
|
"#66d9ef", // 6 - cyan
|
||||||
|
"#fdfff1", // 7 - white
|
||||||
|
"#6e7066", // 8 - bright black
|
||||||
|
"#f92672", // 9 - bright red
|
||||||
|
"#a6e22e", // 10 - bright green
|
||||||
|
"#e6db74", // 11 - bright yellow
|
||||||
|
"#fd971f", // 12 - bright blue
|
||||||
|
"#ae81ff", // 13 - bright magenta
|
||||||
|
"#66d9ef", // 14 - bright cyan
|
||||||
|
"#fdfff1", // 15 - bright white
|
||||||
|
]
|
||||||
|
|
||||||
|
var colors: [SwiftTerm.Color] = []
|
||||||
|
for i in 0..<16 {
|
||||||
|
colors.append(SwiftTerm.Color(hex: defaultPaletteHex[i]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install the ANSI colors
|
||||||
|
terminalView.installColors(colors)
|
||||||
|
}
|
||||||
|
|
||||||
|
class Coordinator: NSObject, LocalProcessTerminalViewDelegate {
|
||||||
|
var tab: Tab
|
||||||
|
weak var terminalView: LocalProcessTerminalView?
|
||||||
|
weak var containerView: FocusableTerminalView?
|
||||||
|
|
||||||
|
init(tab: Tab) {
|
||||||
|
self.tab = tab
|
||||||
|
}
|
||||||
|
|
||||||
|
func sizeChanged(source: LocalProcessTerminalView, newCols: Int, newRows: Int) {
|
||||||
|
// Handle size change
|
||||||
|
}
|
||||||
|
|
||||||
|
func setTerminalTitle(source: LocalProcessTerminalView, title: String) {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
if !title.isEmpty {
|
||||||
|
self.tab.title = title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {
|
||||||
|
if let dir = directory {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.tab.currentDirectory = dir
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func processTerminated(source: TerminalView, exitCode: Int32?) {
|
||||||
|
// Could close tab or show message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
scripts/rebuild.sh
Executable file
18
scripts/rebuild.sh
Executable file
|
|
@ -0,0 +1,18 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Rebuild and restart GhosttyTabs app
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
# Kill existing app if running
|
||||||
|
pkill -9 -f "GhosttyTabs" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Build
|
||||||
|
swift build
|
||||||
|
|
||||||
|
# Copy to app bundle
|
||||||
|
cp .build/debug/GhosttyTabs .build/debug/GhosttyTabs.app/Contents/MacOS/
|
||||||
|
|
||||||
|
# Open the app
|
||||||
|
open .build/debug/GhosttyTabs.app
|
||||||
Loading…
Add table
Add a link
Reference in a new issue