Refactor and Rename to CommandNotch
This commit is contained in:
92
Downterm/CommandNotch/Models/HotkeyBinding.swift
Normal file
92
Downterm/CommandNotch/Models/HotkeyBinding.swift
Normal file
@@ -0,0 +1,92 @@
|
||||
import AppKit
|
||||
import Carbon.HIToolbox
|
||||
|
||||
/// Serializable representation of a keyboard shortcut (modifier flags + key code).
|
||||
/// Stored in UserDefaults as a JSON string.
|
||||
struct HotkeyBinding: Codable, Equatable {
|
||||
var modifiers: UInt // NSEvent.ModifierFlags.rawValue, masked to cmd/shift/ctrl/opt
|
||||
var keyCode: UInt16
|
||||
|
||||
/// Checks whether the given NSEvent matches this binding.
|
||||
func matches(_ event: NSEvent) -> Bool {
|
||||
let mask = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
|
||||
let relevantFlags: NSEvent.ModifierFlags = [.command, .shift, .control, .option]
|
||||
return mask.intersection(relevantFlags).rawValue == modifiers
|
||||
&& event.keyCode == keyCode
|
||||
}
|
||||
|
||||
/// Human-readable label like "⌘⏎" or "⌘⇧T".
|
||||
var displayString: String {
|
||||
var parts: [String] = []
|
||||
let flags = NSEvent.ModifierFlags(rawValue: modifiers)
|
||||
if flags.contains(.control) { parts.append("⌃") }
|
||||
if flags.contains(.option) { parts.append("⌥") }
|
||||
if flags.contains(.shift) { parts.append("⇧") }
|
||||
if flags.contains(.command) { parts.append("⌘") }
|
||||
parts.append(keyName)
|
||||
return parts.joined()
|
||||
}
|
||||
|
||||
private var keyName: String {
|
||||
switch keyCode {
|
||||
case 36: return "⏎"
|
||||
case 48: return "⇥"
|
||||
case 49: return "Space"
|
||||
case 51: return "⌫"
|
||||
case 53: return "⎋"
|
||||
case 123: return "←"
|
||||
case 124: return "→"
|
||||
case 125: return "↓"
|
||||
case 126: return "↑"
|
||||
default:
|
||||
// Try to get the character from the key code
|
||||
let src = TISCopyCurrentKeyboardInputSource().takeRetainedValue()
|
||||
let layoutDataRef = TISGetInputSourceProperty(src, kTISPropertyUnicodeKeyLayoutData)
|
||||
if let layoutDataRef = layoutDataRef {
|
||||
let layoutData = unsafeBitCast(layoutDataRef, to: CFData.self) as Data
|
||||
var deadKeyState: UInt32 = 0
|
||||
var length = 0
|
||||
var chars = [UniChar](repeating: 0, count: 4)
|
||||
layoutData.withUnsafeBytes { ptr in
|
||||
let layoutPtr = ptr.baseAddress!.assumingMemoryBound(to: UCKeyboardLayout.self)
|
||||
UCKeyTranslate(
|
||||
layoutPtr,
|
||||
keyCode,
|
||||
UInt16(kUCKeyActionDisplay),
|
||||
0, // no modifiers for the base character
|
||||
UInt32(LMGetKbdType()),
|
||||
UInt32(kUCKeyTranslateNoDeadKeysBit),
|
||||
&deadKeyState,
|
||||
4,
|
||||
&length,
|
||||
&chars
|
||||
)
|
||||
}
|
||||
if length > 0 {
|
||||
return String(utf16CodeUnits: chars, count: length).uppercased()
|
||||
}
|
||||
}
|
||||
return "Key\(keyCode)"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Serialization
|
||||
|
||||
func toJSON() -> String {
|
||||
(try? String(data: JSONEncoder().encode(self), encoding: .utf8)) ?? "{}"
|
||||
}
|
||||
|
||||
static func fromJSON(_ string: String) -> HotkeyBinding? {
|
||||
guard let data = string.data(using: .utf8) else { return nil }
|
||||
return try? JSONDecoder().decode(HotkeyBinding.self, from: data)
|
||||
}
|
||||
|
||||
// MARK: - Presets
|
||||
|
||||
static let cmdReturn = HotkeyBinding(modifiers: NSEvent.ModifierFlags.command.rawValue, keyCode: 36)
|
||||
static let cmdT = HotkeyBinding(modifiers: NSEvent.ModifierFlags.command.rawValue, keyCode: 17)
|
||||
static let cmdW = HotkeyBinding(modifiers: NSEvent.ModifierFlags.command.rawValue, keyCode: 13)
|
||||
static let cmdShiftRB = HotkeyBinding(modifiers: NSEvent.ModifierFlags([.command, .shift]).rawValue, keyCode: 30) // ]
|
||||
static let cmdShiftLB = HotkeyBinding(modifiers: NSEvent.ModifierFlags([.command, .shift]).rawValue, keyCode: 33) // [
|
||||
static let cmdD = HotkeyBinding(modifiers: NSEvent.ModifierFlags.command.rawValue, keyCode: 2)
|
||||
}
|
||||
173
Downterm/CommandNotch/Models/NotchSettings.swift
Normal file
173
Downterm/CommandNotch/Models/NotchSettings.swift
Normal file
@@ -0,0 +1,173 @@
|
||||
import Foundation
|
||||
|
||||
/// Central registry of all user-configurable notch settings.
|
||||
enum NotchSettings {
|
||||
|
||||
enum Keys {
|
||||
// General
|
||||
static let showOnAllDisplays = "showOnAllDisplays"
|
||||
static let openNotchOnHover = "openNotchOnHover"
|
||||
static let minimumHoverDuration = "minimumHoverDuration"
|
||||
static let showMenuBarIcon = "showMenuBarIcon"
|
||||
static let launchAtLogin = "launchAtLogin"
|
||||
|
||||
// Sizing — closed state
|
||||
static let notchHeight = "notchHeight"
|
||||
static let nonNotchHeight = "nonNotchHeight"
|
||||
static let notchHeightMode = "notchHeightMode"
|
||||
static let nonNotchHeightMode = "nonNotchHeightMode"
|
||||
|
||||
// Sizing — open state
|
||||
static let openWidth = "openWidth"
|
||||
static let openHeight = "openHeight"
|
||||
|
||||
// Appearance
|
||||
static let enableShadow = "enableShadow"
|
||||
static let shadowRadius = "shadowRadius"
|
||||
static let shadowOpacity = "shadowOpacity"
|
||||
static let cornerRadiusScaling = "cornerRadiusScaling"
|
||||
static let notchOpacity = "notchOpacity"
|
||||
static let blurRadius = "blurRadius"
|
||||
|
||||
// Animation
|
||||
static let openSpringResponse = "openSpringResponse"
|
||||
static let openSpringDamping = "openSpringDamping"
|
||||
static let closeSpringResponse = "closeSpringResponse"
|
||||
static let closeSpringDamping = "closeSpringDamping"
|
||||
static let hoverSpringResponse = "hoverSpringResponse"
|
||||
static let hoverSpringDamping = "hoverSpringDamping"
|
||||
|
||||
// Behavior
|
||||
static let enableGestures = "enableGestures"
|
||||
static let gestureSensitivity = "gestureSensitivity"
|
||||
|
||||
// Terminal
|
||||
static let terminalFontSize = "terminalFontSize"
|
||||
static let terminalShell = "terminalShell"
|
||||
|
||||
// Hotkeys — each stores a HotkeyBinding JSON string
|
||||
static let hotkeyToggle = "hotkey_toggle"
|
||||
static let hotkeyNewTab = "hotkey_newTab"
|
||||
static let hotkeyCloseTab = "hotkey_closeTab"
|
||||
static let hotkeyNextTab = "hotkey_nextTab"
|
||||
static let hotkeyPreviousTab = "hotkey_previousTab"
|
||||
static let hotkeyDetachTab = "hotkey_detachTab"
|
||||
}
|
||||
|
||||
enum Defaults {
|
||||
static let showOnAllDisplays: Bool = true
|
||||
static let openNotchOnHover: Bool = true
|
||||
static let minimumHoverDuration: Double = 0.3
|
||||
static let showMenuBarIcon: Bool = true
|
||||
static let launchAtLogin: Bool = false
|
||||
|
||||
static let notchHeight: Double = 32
|
||||
static let nonNotchHeight: Double = 32
|
||||
static let notchHeightMode: Int = 0
|
||||
static let nonNotchHeightMode: Int = 1
|
||||
|
||||
static let openWidth: Double = 640
|
||||
static let openHeight: Double = 350
|
||||
|
||||
static let enableShadow: Bool = true
|
||||
static let shadowRadius: Double = 6
|
||||
static let shadowOpacity: Double = 0.5
|
||||
static let cornerRadiusScaling: Bool = true
|
||||
static let notchOpacity: Double = 1.0
|
||||
static let blurRadius: Double = 0
|
||||
|
||||
static let openSpringResponse: Double = 0.42
|
||||
static let openSpringDamping: Double = 0.8
|
||||
static let closeSpringResponse: Double = 0.45
|
||||
static let closeSpringDamping: Double = 1.0
|
||||
static let hoverSpringResponse: Double = 0.38
|
||||
static let hoverSpringDamping: Double = 0.8
|
||||
|
||||
static let enableGestures: Bool = true
|
||||
static let gestureSensitivity: Double = 0.5
|
||||
|
||||
static let terminalFontSize: Double = 13
|
||||
static let terminalShell: String = ""
|
||||
|
||||
// Default hotkey bindings as JSON
|
||||
static let hotkeyToggle: String = HotkeyBinding.cmdReturn.toJSON()
|
||||
static let hotkeyNewTab: String = HotkeyBinding.cmdT.toJSON()
|
||||
static let hotkeyCloseTab: String = HotkeyBinding.cmdW.toJSON()
|
||||
static let hotkeyNextTab: String = HotkeyBinding.cmdShiftRB.toJSON()
|
||||
static let hotkeyPreviousTab: String = HotkeyBinding.cmdShiftLB.toJSON()
|
||||
static let hotkeyDetachTab: String = HotkeyBinding.cmdD.toJSON()
|
||||
}
|
||||
|
||||
static func registerDefaults() {
|
||||
UserDefaults.standard.register(defaults: [
|
||||
Keys.showOnAllDisplays: Defaults.showOnAllDisplays,
|
||||
Keys.openNotchOnHover: Defaults.openNotchOnHover,
|
||||
Keys.minimumHoverDuration: Defaults.minimumHoverDuration,
|
||||
Keys.showMenuBarIcon: Defaults.showMenuBarIcon,
|
||||
Keys.launchAtLogin: Defaults.launchAtLogin,
|
||||
|
||||
Keys.notchHeight: Defaults.notchHeight,
|
||||
Keys.nonNotchHeight: Defaults.nonNotchHeight,
|
||||
Keys.notchHeightMode: Defaults.notchHeightMode,
|
||||
Keys.nonNotchHeightMode: Defaults.nonNotchHeightMode,
|
||||
|
||||
Keys.openWidth: Defaults.openWidth,
|
||||
Keys.openHeight: Defaults.openHeight,
|
||||
|
||||
Keys.enableShadow: Defaults.enableShadow,
|
||||
Keys.shadowRadius: Defaults.shadowRadius,
|
||||
Keys.shadowOpacity: Defaults.shadowOpacity,
|
||||
Keys.cornerRadiusScaling: Defaults.cornerRadiusScaling,
|
||||
Keys.notchOpacity: Defaults.notchOpacity,
|
||||
Keys.blurRadius: Defaults.blurRadius,
|
||||
|
||||
Keys.openSpringResponse: Defaults.openSpringResponse,
|
||||
Keys.openSpringDamping: Defaults.openSpringDamping,
|
||||
Keys.closeSpringResponse: Defaults.closeSpringResponse,
|
||||
Keys.closeSpringDamping: Defaults.closeSpringDamping,
|
||||
Keys.hoverSpringResponse: Defaults.hoverSpringResponse,
|
||||
Keys.hoverSpringDamping: Defaults.hoverSpringDamping,
|
||||
|
||||
Keys.enableGestures: Defaults.enableGestures,
|
||||
Keys.gestureSensitivity: Defaults.gestureSensitivity,
|
||||
|
||||
Keys.terminalFontSize: Defaults.terminalFontSize,
|
||||
Keys.terminalShell: Defaults.terminalShell,
|
||||
|
||||
Keys.hotkeyToggle: Defaults.hotkeyToggle,
|
||||
Keys.hotkeyNewTab: Defaults.hotkeyNewTab,
|
||||
Keys.hotkeyCloseTab: Defaults.hotkeyCloseTab,
|
||||
Keys.hotkeyNextTab: Defaults.hotkeyNextTab,
|
||||
Keys.hotkeyPreviousTab: Defaults.hotkeyPreviousTab,
|
||||
Keys.hotkeyDetachTab: Defaults.hotkeyDetachTab,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
enum NotchHeightMode: Int, CaseIterable, Identifiable {
|
||||
case matchRealNotchSize = 0
|
||||
case matchMenuBar = 1
|
||||
case custom = 2
|
||||
|
||||
var id: Int { rawValue }
|
||||
var label: String {
|
||||
switch self {
|
||||
case .matchRealNotchSize: return "Match Notch"
|
||||
case .matchMenuBar: return "Match Menu Bar"
|
||||
case .custom: return "Custom"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum NonNotchHeightMode: Int, CaseIterable, Identifiable {
|
||||
case matchMenuBar = 1
|
||||
case custom = 2
|
||||
|
||||
var id: Int { rawValue }
|
||||
var label: String {
|
||||
switch self {
|
||||
case .matchMenuBar: return "Match Menu Bar"
|
||||
case .custom: return "Custom"
|
||||
}
|
||||
}
|
||||
}
|
||||
10
Downterm/CommandNotch/Models/NotchState.swift
Normal file
10
Downterm/CommandNotch/Models/NotchState.swift
Normal file
@@ -0,0 +1,10 @@
|
||||
import Foundation
|
||||
|
||||
/// Represents the two visual states of the notch overlay.
|
||||
enum NotchState: String {
|
||||
/// Compact bar matching the physical notch or menu bar height.
|
||||
case closed
|
||||
|
||||
/// Expanded panel showing content (plain black for now).
|
||||
case open
|
||||
}
|
||||
96
Downterm/CommandNotch/Models/NotchViewModel.swift
Normal file
96
Downterm/CommandNotch/Models/NotchViewModel.swift
Normal file
@@ -0,0 +1,96 @@
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
/// Per-screen observable state that drives the notch UI.
|
||||
@MainActor
|
||||
class NotchViewModel: ObservableObject {
|
||||
|
||||
let screenUUID: String
|
||||
|
||||
@Published var notchState: NotchState = .closed
|
||||
@Published var notchSize: CGSize
|
||||
@Published var closedNotchSize: CGSize
|
||||
@Published var isHovering: Bool = false
|
||||
@Published var isCloseTransitionActive: Bool = false
|
||||
|
||||
let terminalManager = TerminalManager.shared
|
||||
|
||||
/// Set by ScreenManager — routes open/close through proper
|
||||
/// window activation so the terminal receives keyboard input.
|
||||
var requestOpen: (() -> Void)?
|
||||
var requestClose: (() -> Void)?
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
@AppStorage(NotchSettings.Keys.openWidth) private var openWidth = NotchSettings.Defaults.openWidth
|
||||
@AppStorage(NotchSettings.Keys.openHeight) private var openHeight = NotchSettings.Defaults.openHeight
|
||||
|
||||
@AppStorage(NotchSettings.Keys.openSpringResponse) private var openSpringResponse = NotchSettings.Defaults.openSpringResponse
|
||||
@AppStorage(NotchSettings.Keys.openSpringDamping) private var openSpringDamping = NotchSettings.Defaults.openSpringDamping
|
||||
@AppStorage(NotchSettings.Keys.closeSpringResponse) private var closeSpringResponse = NotchSettings.Defaults.closeSpringResponse
|
||||
@AppStorage(NotchSettings.Keys.closeSpringDamping) private var closeSpringDamping = NotchSettings.Defaults.closeSpringDamping
|
||||
|
||||
private var closeTransitionTask: Task<Void, Never>?
|
||||
|
||||
var openAnimation: Animation {
|
||||
.spring(response: openSpringResponse, dampingFraction: openSpringDamping)
|
||||
}
|
||||
var closeAnimation: Animation {
|
||||
.spring(response: closeSpringResponse, dampingFraction: closeSpringDamping)
|
||||
}
|
||||
|
||||
init(screenUUID: String) {
|
||||
self.screenUUID = screenUUID
|
||||
let screen = NSScreen.screens.first { $0.displayUUID == screenUUID } ?? NSScreen.main
|
||||
let closed = screen?.closedNotchSize() ?? CGSize(width: 220, height: 32)
|
||||
self.closedNotchSize = closed
|
||||
self.notchSize = closed
|
||||
}
|
||||
|
||||
func open() {
|
||||
notchSize = CGSize(width: openWidth, height: openHeight)
|
||||
notchState = .open
|
||||
}
|
||||
|
||||
func close() {
|
||||
refreshClosedSize()
|
||||
notchSize = closedNotchSize
|
||||
notchState = .closed
|
||||
}
|
||||
|
||||
func refreshClosedSize() {
|
||||
let screen = NSScreen.screens.first { $0.displayUUID == screenUUID } ?? NSScreen.main
|
||||
closedNotchSize = screen?.closedNotchSize() ?? CGSize(width: 220, height: 32)
|
||||
}
|
||||
|
||||
var openNotchSize: CGSize {
|
||||
CGSize(width: openWidth, height: openHeight)
|
||||
}
|
||||
|
||||
var closeInteractionLockDuration: TimeInterval {
|
||||
max(closeSpringResponse + 0.2, 0.35)
|
||||
}
|
||||
|
||||
func beginCloseTransition() {
|
||||
closeTransitionTask?.cancel()
|
||||
isCloseTransitionActive = 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
|
||||
}
|
||||
|
||||
deinit {
|
||||
closeTransitionTask?.cancel()
|
||||
}
|
||||
}
|
||||
107
Downterm/CommandNotch/Models/TerminalManager.swift
Normal file
107
Downterm/CommandNotch/Models/TerminalManager.swift
Normal file
@@ -0,0 +1,107 @@
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
/// Manages multiple terminal tabs. Singleton shared across all screens —
|
||||
/// whichever notch is currently open displays these tabs.
|
||||
@MainActor
|
||||
class TerminalManager: ObservableObject {
|
||||
|
||||
static let shared = TerminalManager()
|
||||
|
||||
@Published var tabs: [TerminalSession] = []
|
||||
@Published var activeTabIndex: Int = 0
|
||||
|
||||
@AppStorage(NotchSettings.Keys.terminalFontSize)
|
||||
private var fontSize: Double = NotchSettings.Defaults.terminalFontSize
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
private init() {
|
||||
newTab()
|
||||
}
|
||||
|
||||
// MARK: - Active tab
|
||||
|
||||
var activeTab: TerminalSession? {
|
||||
guard tabs.indices.contains(activeTabIndex) else { return nil }
|
||||
return tabs[activeTabIndex]
|
||||
}
|
||||
|
||||
/// Short title for the closed notch bar — the active tab's process name.
|
||||
var activeTitle: String {
|
||||
activeTab?.title ?? "shell"
|
||||
}
|
||||
|
||||
// MARK: - Tab operations
|
||||
|
||||
func newTab() {
|
||||
let session = TerminalSession(fontSize: CGFloat(fontSize))
|
||||
|
||||
// Forward title changes to trigger view updates in this manager
|
||||
session.$title
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] _ in self?.objectWillChange.send() }
|
||||
.store(in: &cancellables)
|
||||
|
||||
tabs.append(session)
|
||||
activeTabIndex = tabs.count - 1
|
||||
}
|
||||
|
||||
func closeTab(at index: Int) {
|
||||
guard tabs.indices.contains(index) else { return }
|
||||
tabs[index].terminate()
|
||||
tabs.remove(at: index)
|
||||
|
||||
// Adjust active index
|
||||
if tabs.isEmpty {
|
||||
newTab()
|
||||
} else if activeTabIndex >= tabs.count {
|
||||
activeTabIndex = tabs.count - 1
|
||||
}
|
||||
}
|
||||
|
||||
func closeActiveTab() {
|
||||
closeTab(at: activeTabIndex)
|
||||
}
|
||||
|
||||
func switchToTab(at index: Int) {
|
||||
guard tabs.indices.contains(index) else { return }
|
||||
activeTabIndex = index
|
||||
}
|
||||
|
||||
func nextTab() {
|
||||
guard tabs.count > 1 else { return }
|
||||
activeTabIndex = (activeTabIndex + 1) % tabs.count
|
||||
}
|
||||
|
||||
func previousTab() {
|
||||
guard tabs.count > 1 else { return }
|
||||
activeTabIndex = (activeTabIndex - 1 + tabs.count) % tabs.count
|
||||
}
|
||||
|
||||
/// Removes the tab at the given index and returns the session so it
|
||||
/// can be hosted in a pop-out window.
|
||||
func detachTab(at index: Int) -> TerminalSession? {
|
||||
guard tabs.indices.contains(index) else { return nil }
|
||||
let session = tabs.remove(at: index)
|
||||
|
||||
if tabs.isEmpty {
|
||||
newTab()
|
||||
} else if activeTabIndex >= tabs.count {
|
||||
activeTabIndex = tabs.count - 1
|
||||
}
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
func detachActiveTab() -> TerminalSession? {
|
||||
detachTab(at: activeTabIndex)
|
||||
}
|
||||
|
||||
/// Updates font size on all existing terminal sessions.
|
||||
func updateAllFontSizes(_ size: CGFloat) {
|
||||
for tab in tabs {
|
||||
tab.updateFontSize(size)
|
||||
}
|
||||
}
|
||||
}
|
||||
131
Downterm/CommandNotch/Models/TerminalSession.swift
Normal file
131
Downterm/CommandNotch/Models/TerminalSession.swift
Normal file
@@ -0,0 +1,131 @@
|
||||
import AppKit
|
||||
import SwiftTerm
|
||||
import Combine
|
||||
|
||||
/// Wraps a single SwiftTerm TerminalView + LocalProcess pair.
|
||||
@MainActor
|
||||
class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, TerminalViewDelegate {
|
||||
|
||||
let id = UUID()
|
||||
let terminalView: TerminalView
|
||||
private var process: LocalProcess?
|
||||
|
||||
@Published var title: String = "shell"
|
||||
@Published var isRunning: Bool = true
|
||||
@Published var currentDirectory: String?
|
||||
|
||||
init(fontSize: CGFloat) {
|
||||
terminalView = TerminalView(frame: NSRect(x: 0, y: 0, width: 600, height: 300))
|
||||
super.init()
|
||||
|
||||
terminalView.terminalDelegate = self
|
||||
|
||||
// Solid black — matches every other element in the notch.
|
||||
// The single `.opacity(notchOpacity)` on ContentView makes
|
||||
// everything uniformly transparent.
|
||||
terminalView.nativeBackgroundColor = .black
|
||||
terminalView.nativeForegroundColor = .init(white: 0.9, alpha: 1.0)
|
||||
|
||||
let font = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular)
|
||||
terminalView.font = font
|
||||
|
||||
startShell()
|
||||
}
|
||||
|
||||
// MARK: - Shell management
|
||||
|
||||
private func startShell() {
|
||||
let shellPath = resolveShell()
|
||||
let shellName = (shellPath as NSString).lastPathComponent
|
||||
let loginExecName = "-\(shellName)"
|
||||
|
||||
FileManager.default.changeCurrentDirectoryPath(NSHomeDirectory())
|
||||
let proc = LocalProcess(delegate: self)
|
||||
// Launch as a login shell so user startup files initialize PATH/tools.
|
||||
proc.startProcess(
|
||||
executable: shellPath,
|
||||
args: ["-l"],
|
||||
environment: nil,
|
||||
execName: loginExecName
|
||||
)
|
||||
process = proc
|
||||
title = shellName
|
||||
}
|
||||
|
||||
private func resolveShell() -> String {
|
||||
let custom = UserDefaults.standard.string(forKey: NotchSettings.Keys.terminalShell) ?? ""
|
||||
if !custom.isEmpty && FileManager.default.isExecutableFile(atPath: custom) {
|
||||
return custom
|
||||
}
|
||||
return ProcessInfo.processInfo.environment["SHELL"] ?? "/bin/zsh"
|
||||
}
|
||||
|
||||
func updateFontSize(_ size: CGFloat) {
|
||||
terminalView.font = NSFont.monospacedSystemFont(ofSize: size, weight: .regular)
|
||||
}
|
||||
|
||||
func terminate() {
|
||||
process?.terminate()
|
||||
process = nil
|
||||
isRunning = false
|
||||
}
|
||||
|
||||
// MARK: - LocalProcessDelegate
|
||||
|
||||
nonisolated func processTerminated(_ source: LocalProcess, exitCode: Int32?) {
|
||||
Task { @MainActor in self.isRunning = false }
|
||||
}
|
||||
|
||||
nonisolated func dataReceived(slice: ArraySlice<UInt8>) {
|
||||
let data = slice
|
||||
Task { @MainActor in self.terminalView.feed(byteArray: data) }
|
||||
}
|
||||
|
||||
nonisolated func getWindowSize() -> winsize {
|
||||
var ws = winsize()
|
||||
ws.ws_col = 80
|
||||
ws.ws_row = 24
|
||||
return ws
|
||||
}
|
||||
|
||||
// MARK: - TerminalViewDelegate
|
||||
|
||||
func send(source: TerminalView, data: ArraySlice<UInt8>) {
|
||||
process?.send(data: data)
|
||||
}
|
||||
|
||||
func setTerminalTitle(source: TerminalView, title: String) {
|
||||
self.title = title.isEmpty ? "shell" : title
|
||||
}
|
||||
|
||||
func sizeChanged(source: TerminalView, newCols: Int, newRows: Int) {
|
||||
guard newCols > 0, newRows > 0 else { return }
|
||||
guard let proc = process else { return }
|
||||
let fd = proc.childfd
|
||||
guard fd >= 0 else { return }
|
||||
|
||||
var ws = winsize()
|
||||
ws.ws_col = UInt16(newCols)
|
||||
ws.ws_row = UInt16(newRows)
|
||||
_ = ioctl(fd, TIOCSWINSZ, &ws)
|
||||
}
|
||||
|
||||
func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {
|
||||
currentDirectory = directory
|
||||
}
|
||||
|
||||
func scrolled(source: TerminalView, position: Double) {}
|
||||
func rangeChanged(source: TerminalView, startY: Int, endY: Int) {}
|
||||
|
||||
func clipboardCopy(source: TerminalView, content: Data) {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setData(content, forType: .string)
|
||||
}
|
||||
|
||||
func requestOpenLink(source: TerminalView, link: String, params: [String : String]) {
|
||||
if let url = URL(string: link) { NSWorkspace.shared.open(url) }
|
||||
}
|
||||
|
||||
func bell(source: TerminalView) { NSSound.beep() }
|
||||
func iTermContent(source: TerminalView, content: ArraySlice<UInt8>) {}
|
||||
}
|
||||
Reference in New Issue
Block a user