Fix command modified keys. Add plan for splitscreen
This commit is contained in:
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, @precon
|
|||||||
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
|
private let configuredShellPath: String
|
||||||
|
|
||||||
@@ -26,10 +27,17 @@ class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, @precon
|
|||||||
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() {
|
||||||
@@ -58,6 +66,26 @@ class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, @precon
|
|||||||
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,45 @@ packages:
|
|||||||
SwiftTerm:
|
SwiftTerm:
|
||||||
url: https://github.com/migueldeicaza/SwiftTerm.git
|
url: https://github.com/migueldeicaza/SwiftTerm.git
|
||||||
from: "1.2.0"
|
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:
|
targets:
|
||||||
CommandNotch:
|
CommandNotch:
|
||||||
type: application
|
type: application
|
||||||
@@ -29,9 +68,9 @@ targets:
|
|||||||
properties:
|
properties:
|
||||||
CFBundleName: CommandNotch
|
CFBundleName: CommandNotch
|
||||||
CFBundleDisplayName: CommandNotch
|
CFBundleDisplayName: CommandNotch
|
||||||
CFBundleIdentifier: com.commandnotch.app
|
CFBundleIdentifier: "$(PRODUCT_BUNDLE_IDENTIFIER)"
|
||||||
CFBundleVersion: "1"
|
CFBundleVersion: "1"
|
||||||
CFBundleShortVersionString: "0.2.0"
|
CFBundleShortVersionString: "0.0.3"
|
||||||
CFBundlePackageType: APPL
|
CFBundlePackageType: APPL
|
||||||
CFBundleExecutable: CommandNotch
|
CFBundleExecutable: CommandNotch
|
||||||
LSMinimumSystemVersion: "14.0"
|
LSMinimumSystemVersion: "14.0"
|
||||||
|
|||||||
351
docs/workspace-split-pane-plan.md
Normal file
351
docs/workspace-split-pane-plan.md
Normal 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.
|
||||||
Reference in New Issue
Block a user