mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Terminal Work In Progress
This commit is contained in:
parent
40457aa84c
commit
e59d35d89a
12 changed files with 570 additions and 77 deletions
157
DynamicIsland/managers/TerminalManager.swift
Normal file
157
DynamicIsland/managers/TerminalManager.swift
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
/*
|
||||
* 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
|
||||
|
||||
/// 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 `containerView` (NSView) 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"
|
||||
|
||||
/// Bumped on restart so the `NSViewRepresentable` can recreate via `.id()`.
|
||||
@Published var sessionGeneration: Int = 0
|
||||
|
||||
/// Stable container returned to SwiftUI — never deallocated.
|
||||
let containerView: NSView = {
|
||||
let v = NSView(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
|
||||
return
|
||||
}
|
||||
|
||||
let view = LocalProcessTerminalView(frame: containerView.bounds)
|
||||
view.autoresizingMask = [.width, .height]
|
||||
|
||||
// Apply font size from settings
|
||||
let fontSize = CGFloat(Defaults[.terminalFontSize])
|
||||
if let font = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular) as NSFont? {
|
||||
view.font = font
|
||||
}
|
||||
|
||||
// Apply opacity
|
||||
view.layer?.opacity = Float(Defaults[.terminalOpacity])
|
||||
|
||||
// Configure the dark terminal appearance
|
||||
view.nativeBackgroundColor = .black
|
||||
view.nativeForegroundColor = .white
|
||||
|
||||
view.processDelegate = delegate
|
||||
|
||||
// Mount inside the stable container
|
||||
containerView.subviews.forEach { $0.removeFromSuperview() }
|
||||
containerView.addSubview(view)
|
||||
terminalView = view
|
||||
}
|
||||
|
||||
/// 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.
|
||||
func restartShell() {
|
||||
// Terminate the running process gracefully
|
||||
terminalView?.terminate()
|
||||
// Remove old terminal from container
|
||||
terminalView?.removeFromSuperview()
|
||||
terminalView = nil
|
||||
isProcessRunning = false
|
||||
terminalTitle = "Terminal"
|
||||
// Bump generation so the SwiftUI representable re-mounts
|
||||
sessionGeneration += 1
|
||||
}
|
||||
|
||||
/// Updates the terminal title from the shell escape sequence.
|
||||
func updateTitle(_ title: String) {
|
||||
terminalTitle = title
|
||||
}
|
||||
|
||||
/// Updates font size on the live terminal view.
|
||||
func applyFontSize(_ size: Double) {
|
||||
guard let view = terminalView else { return }
|
||||
if let font = NSFont.monospacedSystemFont(ofSize: CGFloat(size), weight: .regular) as NSFont? {
|
||||
view.font = font
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates opacity on the live terminal view.
|
||||
func applyOpacity(_ opacity: Double) {
|
||||
guard let view = terminalView else { return }
|
||||
view.layer?.opacity = Float(opacity)
|
||||
}
|
||||
|
||||
// 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)" }
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue