Atoll/DynamicIsland/managers/TerminalManager.swift
2026-03-11 13:29:10 +05:30

348 lines
13 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* Atoll (DynamicIsland)
* Copyright (C) 2024-2026 Atoll Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import Foundation
import SwiftUI
import SwiftTerm
import Defaults
// MARK: - Stable Container
/// Container NSView that shields its terminal child from zero-sized frames.
///
/// When the notch closes, SwiftUI removes the `NSViewRepresentable` from the
/// hierarchy — but later re-adds the same `containerView` on reopen. During
/// that insertion, SwiftUI momentarily sets the frame to `.zero`. Without
/// protection, AppKit's autoresizing mask propagates the zero frame to the
/// terminal child, causing SwiftTerm's `processSizeChange` to resize the
/// emulator to 2 cols × 1 row (the enforced minimum), which destroys the
/// scrollback buffer.
///
/// We bypass `super.resizeSubviews(withOldSize:)` entirely and manually set
/// children to fill the container's bounds. This avoids the autoresizing
/// calculation which produces corrupt geometry when `oldSize` was `.zero`
/// (recorded during the transient removal) but the child kept its previous
/// large frame.
final class StableTerminalContainerView: NSView {
override func resizeSubviews(withOldSize oldSize: NSSize) {
let size = bounds.size
// Block degenerate sizes — the terminal would collapse to 2×1
guard size.width >= 10, size.height >= 10 else { return }
// Manually fill children to bounds — bypasses autoresizing math
// that breaks when oldSize was zero but child kept its old frame.
for child in subviews {
child.frame = bounds
}
}
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
guard window != nil else { return }
// When re-inserted into a window (notch reopened), force a full
// redraw so SwiftTerm re-renders the terminal buffer contents.
for child in subviews {
child.needsDisplay = true
}
}
}
// MARK: - Terminal Manager
/// Manages the Guake-style dropdown terminal session lifecycle.
/// The terminal is lazily created when the user first switches to the terminal tab,
/// and the process is kept alive across notch open/close cycles.
///
/// Uses a stable `StableTerminalContainerView` as the host so that SwiftUI's
/// `NSViewRepresentable` lifecycle (make/update/dismantle) never tears down
/// the actual terminal. The `LocalProcessTerminalView` is added as a subview
/// of the container and survives notch close/open cycles.
@MainActor
class TerminalManager: ObservableObject {
static let shared = TerminalManager()
/// Whether a shell process is currently running.
@Published var isProcessRunning: Bool = false
/// The current terminal title reported by the shell.
@Published var terminalTitle: String = "Terminal"
/// Stable container returned to SwiftUI — never deallocated.
/// Uses `StableTerminalContainerView` to prevent zero-frame transients
/// from destroying the scrollback buffer.
let containerView: StableTerminalContainerView = {
let v = StableTerminalContainerView(frame: .zero)
v.autoresizingMask = [.width, .height]
v.wantsLayer = true
return v
}()
/// The actual terminal view (child of `containerView`).
private(set) var terminalView: LocalProcessTerminalView?
private init() {}
// MARK: - Lifecycle
/// Ensures the terminal view exists inside the container and returns the container.
/// Call this from the `NSViewRepresentable` wrapper.
func ensureTerminalView(delegate: LocalProcessTerminalViewDelegate) {
if let existing = terminalView, existing.superview === containerView {
// Already mounted — just re-wire the delegate in case the coordinator changed.
existing.processDelegate = delegate
// Force a redraw — the view may have been off-screen (notch closed)
// and needs to re-render the terminal buffer.
existing.needsDisplay = true
return
}
// Use the container's current bounds if valid, otherwise a reasonable
// default. SwiftTerm's init calls setupOptions() which reads the
// frame — a zero frame creates a 2×1 terminal that is corrected
// once the container gets its proper layout size.
let initialFrame = containerView.bounds.size.width >= 10
? containerView.bounds
: CGRect(x: 0, y: 0, width: 400, height: 300)
let view = LocalProcessTerminalView(frame: initialFrame)
view.autoresizingMask = [.width, .height]
// Apply all settings from Defaults
applyAllSettings(to: view)
view.processDelegate = delegate
// Mount inside the stable container
containerView.subviews.forEach { $0.removeFromSuperview() }
containerView.addSubview(view)
terminalView = view
// If the container already has a valid size, snap the child to it.
let containerSize = containerView.bounds.size
if containerSize.width >= 10, containerSize.height >= 10 {
view.frame = containerView.bounds
}
}
/// Starts the shell process if not already running.
func startShellProcess() {
guard let view = terminalView, !isProcessRunning else { return }
let shell = Defaults[.terminalShellPath]
let execName = "-" + (shell as NSString).lastPathComponent // login shell convention
view.startProcess(
executable: shell,
args: [],
environment: buildEnvironment(),
execName: execName
)
isProcessRunning = true
}
/// Called when the shell process terminates.
func processDidTerminate(exitCode: Int32?) {
isProcessRunning = false
}
/// Restarts the shell by tearing down the old terminal and creating a fresh one.
///
/// Instead of bumping a generation counter (which forced SwiftUI to destroy
/// and recreate the `NSViewRepresentable` — recycling the same `containerView`
/// across identities which broke layout), we simply nil out `terminalView`
/// and change `@Published` state. SwiftUI's `updateNSView` will fire,
/// see the nil terminal, and call `ensureTerminalView` to mount a new one.
func restartShell() {
// Terminate the running process gracefully
terminalView?.terminate()
// Remove old terminal from container
terminalView?.removeFromSuperview()
terminalView = nil
isProcessRunning = false
terminalTitle = "Terminal"
}
/// Updates the terminal title from the shell escape sequence.
func updateTitle(_ title: String) {
terminalTitle = title
}
// MARK: - Font Resolution
/// Resolves the terminal font from the user's chosen family and size.
///
/// - If `family` is empty, returns the system monospaced font.
/// - Otherwise tries `NSFont(name:size:)` and falls back to system monospaced
/// when the name is invalid or the font is not installed.
private func resolveFont(family: String, size: CGFloat) -> NSFont {
if !family.isEmpty, let custom = NSFont(name: family, size: size) {
return custom
}
return NSFont.monospacedSystemFont(ofSize: size, weight: .regular)
}
// MARK: - Settings Application
/// Applies all persisted settings to a terminal view at creation time.
private func applyAllSettings(to view: LocalProcessTerminalView) {
// Font
let fontSize = CGFloat(Defaults[.terminalFontSize])
let fontFamily = Defaults[.terminalFontFamily]
view.font = resolveFont(family: fontFamily, size: fontSize)
// Opacity
view.layer?.opacity = Float(Defaults[.terminalOpacity])
// Colors
view.nativeBackgroundColor = NSColor(Defaults[.terminalBackgroundColor])
view.nativeForegroundColor = NSColor(Defaults[.terminalForegroundColor])
view.caretColor = NSColor(Defaults[.terminalCursorColor])
// Cursor style
let cursorStyle = TerminalCursorStyleOption(rawValue: Defaults[.terminalCursorStyle])
?? .blinkBlock
view.getTerminal().options.cursorStyle = cursorStyle.swiftTermStyle
// Scrollback
let scrollback = Defaults[.terminalScrollbackLines]
view.getTerminal().buffer.changeHistorySize(scrollback)
view.getTerminal().options.scrollback = scrollback
// Input behavior
view.optionAsMetaKey = Defaults[.terminalOptionAsMeta]
view.allowMouseReporting = Defaults[.terminalMouseReporting]
// Rendering
view.useBrightColors = Defaults[.terminalBoldAsBright]
}
/// Updates font size on the live terminal view.
func applyFontSize(_ size: Double) {
guard let view = terminalView else { return }
let fontFamily = Defaults[.terminalFontFamily]
view.font = resolveFont(family: fontFamily, size: CGFloat(size))
}
/// Updates font family on the live terminal view.
func applyFontFamily(_ family: String) {
guard let view = terminalView else { return }
let fontSize = CGFloat(Defaults[.terminalFontSize])
view.font = resolveFont(family: family, size: fontSize)
}
/// Updates opacity on the live terminal view.
func applyOpacity(_ opacity: Double) {
guard let view = terminalView else { return }
view.layer?.opacity = Float(opacity)
}
/// Updates cursor style on the live terminal view.
func applyCursorStyle(_ style: TerminalCursorStyleOption) {
guard let view = terminalView else { return }
view.getTerminal().options.cursorStyle = style.swiftTermStyle
view.setNeedsDisplay(view.bounds)
}
/// Updates scrollback buffer size on the live terminal view.
func applyScrollback(_ lines: Int) {
guard let view = terminalView else { return }
view.getTerminal().buffer.changeHistorySize(lines)
view.getTerminal().options.scrollback = lines
}
/// Updates option-as-meta on the live terminal view.
func applyOptionAsMeta(_ enabled: Bool) {
guard let view = terminalView else { return }
view.optionAsMetaKey = enabled
}
/// Updates mouse reporting on the live terminal view.
func applyMouseReporting(_ enabled: Bool) {
guard let view = terminalView else { return }
view.allowMouseReporting = enabled
}
/// Updates bold-as-bright on the live terminal view.
func applyBoldAsBright(_ enabled: Bool) {
guard let view = terminalView else { return }
view.useBrightColors = enabled
}
/// Updates background color on the live terminal view.
func applyBackgroundColor(_ color: SwiftUI.Color) {
guard let view = terminalView else { return }
view.nativeBackgroundColor = NSColor(color)
}
/// Updates foreground color on the live terminal view.
func applyForegroundColor(_ color: SwiftUI.Color) {
guard let view = terminalView else { return }
view.nativeForegroundColor = NSColor(color)
}
/// Updates cursor color on the live terminal view.
func applyCursorColor(_ color: SwiftUI.Color) {
guard let view = terminalView else { return }
view.caretColor = NSColor(color)
}
// MARK: - Environment
/// Builds the environment for the child shell process.
private func buildEnvironment() -> [String] {
var env = ProcessInfo.processInfo.environment
env["TERM"] = "xterm-256color"
env["LANG"] = env["LANG"] ?? "en_US.UTF-8"
// Remove TERM_PROGRAM if set by a parent terminal
env.removeValue(forKey: "TERM_PROGRAM")
return env.map { "\($0.key)=\($0.value)" }
}
}
// MARK: - Cursor Style Bridge
/// Codable-friendly cursor style enum that bridges to SwiftTerm's `CursorStyle`.
enum TerminalCursorStyleOption: String, CaseIterable, Defaults.Serializable {
case blinkBlock = "blinkBlock"
case steadyBlock = "steadyBlock"
case blinkUnderline = "blinkUnderline"
case steadyUnderline = "steadyUnderline"
case blinkBar = "blinkBar"
case steadyBar = "steadyBar"
var swiftTermStyle: CursorStyle {
switch self {
case .blinkBlock: return .blinkBlock
case .steadyBlock: return .steadyBlock
case .blinkUnderline: return .blinkUnderline
case .steadyUnderline: return .steadyUnderline
case .blinkBar: return .blinkBar
case .steadyBar: return .steadyBar
}
}
var displayName: String {
switch self {
case .blinkBlock: return "Block (blinking)"
case .steadyBlock: return "Block (steady)"
case .blinkUnderline: return "Underline (blinking)"
case .steadyUnderline: return "Underline (steady)"
case .blinkBar: return "Bar (blinking)"
case .steadyBar: return "Bar (steady)"
}
}
}