813 lines
33 KiB
Swift
813 lines
33 KiB
Swift
import SwiftUI
|
||
import UserNotifications
|
||
import GhosttyKit
|
||
|
||
extension Ghostty {
|
||
/// Render a terminal for the active app in the environment.
|
||
struct Terminal: View {
|
||
@EnvironmentObject private var ghostty: Ghostty.App
|
||
|
||
var body: some View {
|
||
if let app = self.ghostty.app {
|
||
SurfaceForApp(app) { surfaceView in
|
||
SurfaceWrapper(surfaceView: surfaceView)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Yields a SurfaceView for a ghostty app that can then be used however you want.
|
||
struct SurfaceForApp<Content: View>: View {
|
||
let content: ((SurfaceView) -> Content)
|
||
|
||
@StateObject private var surfaceView: SurfaceView
|
||
|
||
init(_ app: ghostty_app_t, @ViewBuilder content: @escaping ((SurfaceView) -> Content)) {
|
||
_surfaceView = StateObject(wrappedValue: SurfaceView(app))
|
||
self.content = content
|
||
}
|
||
|
||
var body: some View {
|
||
content(surfaceView)
|
||
}
|
||
}
|
||
|
||
struct SurfaceWrapper: View {
|
||
// The surface to create a view for. This must be created upstream. As long as this
|
||
// remains the same, the surface that is being rendered remains the same.
|
||
@ObservedObject var surfaceView: SurfaceView
|
||
|
||
// True if this surface is part of a split view. This is important to know so
|
||
// we know whether to dim the surface out of focus.
|
||
var isSplit: Bool = false
|
||
|
||
// Maintain whether our view has focus or not
|
||
@FocusState private var surfaceFocus: Bool
|
||
|
||
// Maintain whether our window has focus (is key) or not
|
||
@State private var windowFocus: Bool = true
|
||
|
||
// True if we're hovering over the left URL view, so we can show it on the right.
|
||
@State private var isHoveringURLLeft: Bool = false
|
||
|
||
#if canImport(AppKit)
|
||
// Observe SecureInput to detect when its enabled
|
||
@ObservedObject private var secureInput = SecureInput.shared
|
||
#endif
|
||
|
||
@EnvironmentObject private var ghostty: Ghostty.App
|
||
|
||
var body: some View {
|
||
let center = NotificationCenter.default
|
||
|
||
ZStack {
|
||
// We use a GeometryReader to get the frame bounds so that our metal surface
|
||
// is up to date. See TerminalSurfaceView for why we don't use the NSView
|
||
// resize callback.
|
||
GeometryReader { geo in
|
||
#if canImport(AppKit)
|
||
let pubBecomeKey = center.publisher(for: NSWindow.didBecomeKeyNotification)
|
||
let pubResign = center.publisher(for: NSWindow.didResignKeyNotification)
|
||
#endif
|
||
|
||
SurfaceRepresentable(view: surfaceView, size: geo.size)
|
||
.focused($surfaceFocus)
|
||
.focusedValue(\.ghosttySurfacePwd, surfaceView.pwd)
|
||
.focusedValue(\.ghosttySurfaceView, surfaceView)
|
||
.focusedValue(\.ghosttySurfaceCellSize, surfaceView.cellSize)
|
||
#if canImport(AppKit)
|
||
.onReceive(pubBecomeKey) { notification in
|
||
guard let window = notification.object as? NSWindow else { return }
|
||
guard let surfaceWindow = surfaceView.window else { return }
|
||
windowFocus = surfaceWindow == window
|
||
}
|
||
.onReceive(pubResign) { notification in
|
||
guard let window = notification.object as? NSWindow else { return }
|
||
guard let surfaceWindow = surfaceView.window else { return }
|
||
if (surfaceWindow == window) {
|
||
windowFocus = false
|
||
}
|
||
}
|
||
#endif
|
||
|
||
// If our geo size changed then we show the resize overlay as configured.
|
||
if let surfaceSize = surfaceView.surfaceSize {
|
||
SurfaceResizeOverlay(
|
||
geoSize: geo.size,
|
||
size: surfaceSize,
|
||
overlay: ghostty.config.resizeOverlay,
|
||
position: ghostty.config.resizeOverlayPosition,
|
||
duration: ghostty.config.resizeOverlayDuration,
|
||
focusInstant: surfaceView.focusInstant)
|
||
|
||
}
|
||
}
|
||
.ghosttySurfaceView(surfaceView)
|
||
|
||
// Progress report
|
||
if let progressReport = surfaceView.progressReport, progressReport.state != .remove {
|
||
VStack(spacing: 0) {
|
||
SurfaceProgressBar(report: progressReport)
|
||
Spacer()
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||
.allowsHitTesting(false)
|
||
.transition(.opacity)
|
||
}
|
||
|
||
#if canImport(AppKit)
|
||
// If we are in the middle of a key sequence, then we show a visual element. We only
|
||
// support this on macOS currently although in theory we can support mobile with keyboards!
|
||
if !surfaceView.keySequence.isEmpty {
|
||
let padding: CGFloat = 5
|
||
VStack {
|
||
Spacer()
|
||
|
||
HStack {
|
||
Text(verbatim: "Pending Key Sequence:")
|
||
ForEach(0..<surfaceView.keySequence.count, id: \.description) { index in
|
||
let key = surfaceView.keySequence[index]
|
||
Text(verbatim: key.description)
|
||
.font(.system(.body, design: .monospaced))
|
||
.padding(3)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 5)
|
||
.fill(Color(NSColor.selectedTextBackgroundColor))
|
||
)
|
||
}
|
||
}
|
||
.padding(.init(top: padding, leading: padding, bottom: padding, trailing: padding))
|
||
.frame(maxWidth: .infinity)
|
||
.background(.background)
|
||
}
|
||
}
|
||
#endif
|
||
|
||
// If we have a URL from hovering a link, we show that.
|
||
if let url = surfaceView.hoverUrl {
|
||
let padding: CGFloat = 5
|
||
let cornerRadius: CGFloat = 9
|
||
ZStack {
|
||
HStack {
|
||
Spacer()
|
||
VStack(alignment: .leading) {
|
||
Spacer()
|
||
|
||
Text(verbatim: url)
|
||
.padding(.init(top: padding, leading: padding, bottom: padding, trailing: padding))
|
||
.background(
|
||
UnevenRoundedRectangle(cornerRadii: .init(topLeading: cornerRadius))
|
||
.fill(.background)
|
||
)
|
||
.lineLimit(1)
|
||
.truncationMode(.middle)
|
||
.opacity(isHoveringURLLeft ? 1 : 0)
|
||
}
|
||
}
|
||
|
||
HStack {
|
||
VStack(alignment: .leading) {
|
||
Spacer()
|
||
|
||
Text(verbatim: url)
|
||
.padding(.init(top: padding, leading: padding, bottom: padding, trailing: padding))
|
||
.background(
|
||
UnevenRoundedRectangle(cornerRadii: .init(topTrailing: cornerRadius))
|
||
.fill(.background)
|
||
)
|
||
.lineLimit(1)
|
||
.truncationMode(.middle)
|
||
.opacity(isHoveringURLLeft ? 0 : 1)
|
||
.onHover(perform: { hovering in
|
||
isHoveringURLLeft = hovering
|
||
})
|
||
}
|
||
Spacer()
|
||
}
|
||
}
|
||
}
|
||
|
||
#if canImport(AppKit)
|
||
// If we have secure input enabled and we're the focused surface and window
|
||
// then we want to show the secure input overlay.
|
||
if (ghostty.config.secureInputIndication &&
|
||
secureInput.enabled &&
|
||
surfaceFocus &&
|
||
windowFocus) {
|
||
SecureInputOverlay()
|
||
}
|
||
#endif
|
||
|
||
// Search overlay
|
||
if surfaceView.searchState != nil {
|
||
SurfaceSearchOverlay(surfaceView: surfaceView, searchState: $surfaceView.searchState)
|
||
}
|
||
|
||
// Show bell border if enabled
|
||
if (ghostty.config.bellFeatures.contains(.border)) {
|
||
BellBorderOverlay(bell: surfaceView.bell)
|
||
}
|
||
|
||
// If our surface is not healthy, then we render an error view over it.
|
||
if (!surfaceView.healthy) {
|
||
Rectangle().fill(ghostty.config.backgroundColor)
|
||
SurfaceRendererUnhealthyView()
|
||
} else if (surfaceView.error != nil) {
|
||
Rectangle().fill(ghostty.config.backgroundColor)
|
||
SurfaceErrorView()
|
||
}
|
||
|
||
// If we're part of a split view and don't have focus, we put a semi-transparent
|
||
// rectangle above our view to make it look unfocused. We use "surfaceFocus"
|
||
// because we want to keep our focused surface dark even if we don't have window
|
||
// focus.
|
||
if (isSplit && !surfaceFocus) {
|
||
let overlayOpacity = ghostty.config.unfocusedSplitOpacity;
|
||
if (overlayOpacity > 0) {
|
||
Rectangle()
|
||
.fill(ghostty.config.unfocusedSplitFill)
|
||
.allowsHitTesting(false)
|
||
.opacity(overlayOpacity)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
struct SurfaceRendererUnhealthyView: View {
|
||
var body: some View {
|
||
HStack {
|
||
Image("AppIconImage")
|
||
.resizable()
|
||
.scaledToFit()
|
||
.frame(width: 128, height: 128)
|
||
|
||
VStack(alignment: .leading) {
|
||
Text("Oh, no. 😭").font(.title)
|
||
Text("""
|
||
The renderer has failed. This is usually due to exhausting
|
||
available GPU memory. Please free up available resources.
|
||
""".replacingOccurrences(of: "\n", with: " ")
|
||
)
|
||
.frame(maxWidth: 350)
|
||
}
|
||
}
|
||
.padding()
|
||
}
|
||
}
|
||
|
||
struct SurfaceErrorView: View {
|
||
var body: some View {
|
||
HStack {
|
||
Image("AppIconImage")
|
||
.resizable()
|
||
.scaledToFit()
|
||
.frame(width: 128, height: 128)
|
||
|
||
VStack(alignment: .leading) {
|
||
Text("Oh, no. 😭").font(.title)
|
||
Text("""
|
||
The terminal failed to initialize. Please check the logs for
|
||
more information. This is usually a bug.
|
||
""".replacingOccurrences(of: "\n", with: " ")
|
||
)
|
||
.frame(maxWidth: 350)
|
||
}
|
||
}
|
||
.padding()
|
||
}
|
||
}
|
||
|
||
|
||
|
||
// This is the resize overlay that shows on top of a surface to show the current
|
||
// size during a resize operation.
|
||
struct SurfaceResizeOverlay: View {
|
||
let geoSize: CGSize
|
||
let size: ghostty_surface_size_s
|
||
let overlay: Ghostty.Config.ResizeOverlay
|
||
let position: Ghostty.Config.ResizeOverlayPosition
|
||
let duration: UInt
|
||
let focusInstant: ContinuousClock.Instant?
|
||
|
||
// This is the last size that we processed. This is how we handle our
|
||
// timer state.
|
||
@State var lastSize: CGSize? = nil
|
||
|
||
// Ready is set to true after a short delay. This avoids some of the
|
||
// challenges of initial view sizing from SwiftUI.
|
||
@State var ready: Bool = false
|
||
|
||
// Fixed value set based on personal taste.
|
||
private let padding: CGFloat = 5
|
||
|
||
// This computed boolean is set to true when the overlay should be hidden.
|
||
private var hidden: Bool {
|
||
// If we aren't ready yet then we wait...
|
||
if (!ready) { return true; }
|
||
|
||
// Hidden if we already processed this size.
|
||
if (lastSize == geoSize) { return true; }
|
||
|
||
// If we were focused recently we hide it as well. This avoids showing
|
||
// the resize overlay when SwiftUI is lazily resizing.
|
||
if let instant = focusInstant {
|
||
let d = instant.duration(to: ContinuousClock.now)
|
||
if (d < .milliseconds(500)) {
|
||
// Avoid this size completely. We can't set values during
|
||
// view updates so we have to defer this to another tick.
|
||
DispatchQueue.main.async {
|
||
lastSize = geoSize
|
||
}
|
||
|
||
return true;
|
||
}
|
||
}
|
||
|
||
// Hidden depending on overlay config
|
||
switch (overlay) {
|
||
case .never: return true;
|
||
case .always: return false;
|
||
case .after_first: return lastSize == nil;
|
||
}
|
||
}
|
||
|
||
var body: some View {
|
||
VStack {
|
||
if (!position.top()) {
|
||
Spacer()
|
||
}
|
||
|
||
HStack {
|
||
if (!position.left()) {
|
||
Spacer()
|
||
}
|
||
|
||
Text(verbatim: "\(size.columns) ⨯ \(size.rows)")
|
||
.padding(.init(top: padding, leading: padding, bottom: padding, trailing: padding))
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 4)
|
||
.fill(.background)
|
||
.shadow(radius: 3)
|
||
)
|
||
.lineLimit(1)
|
||
.truncationMode(.tail)
|
||
|
||
if (!position.right()) {
|
||
Spacer()
|
||
}
|
||
}
|
||
|
||
if (!position.bottom()) {
|
||
Spacer()
|
||
}
|
||
}
|
||
.allowsHitTesting(false)
|
||
.opacity(hidden ? 0 : 1)
|
||
.task {
|
||
// Sleep chosen arbitrarily... a better long term solution would be to detect
|
||
// when the size stabilizes (coalesce a value) for the first time and then after
|
||
// that show the resize overlay consistently.
|
||
try? await Task.sleep(nanoseconds: 500 * 1_000_000)
|
||
ready = true
|
||
}
|
||
.task(id: geoSize) {
|
||
// By ID-ing the task on the geoSize, we get the task to restart if our
|
||
// geoSize changes. This also ensures that future resize overlays are shown
|
||
// properly.
|
||
|
||
// We only sleep if we're ready. If we're not ready then we want to set
|
||
// our last size right away to avoid a flash.
|
||
if (ready) {
|
||
try? await Task.sleep(nanoseconds: UInt64(duration) * 1_000_000)
|
||
}
|
||
|
||
lastSize = geoSize
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Search overlay view that displays a search bar with input field and navigation buttons.
|
||
struct SurfaceSearchOverlay: View {
|
||
let surfaceView: SurfaceView
|
||
@Binding var searchState: SurfaceView.SearchState?
|
||
@State private var corner: Corner = .topRight
|
||
@State private var dragOffset: CGSize = .zero
|
||
@State private var barSize: CGSize = .zero
|
||
@FocusState private var isSearchFieldFocused: Bool
|
||
|
||
private let padding: CGFloat = 8
|
||
|
||
var body: some View {
|
||
GeometryReader { geo in
|
||
HStack(spacing: 8) {
|
||
TextField("Search", text: Binding(
|
||
get: { searchState?.needle ?? "" },
|
||
set: { searchState?.needle = $0 }
|
||
))
|
||
.textFieldStyle(.plain)
|
||
.frame(width: 180)
|
||
.padding(.horizontal, 8)
|
||
.padding(.vertical, 6)
|
||
.background(Color.primary.opacity(0.1))
|
||
.cornerRadius(6)
|
||
.focused($isSearchFieldFocused)
|
||
.onExitCommand {
|
||
Ghostty.moveFocus(to: surfaceView)
|
||
}
|
||
|
||
Button(action: {
|
||
guard let surface = surfaceView.surface else { return }
|
||
let action = "navigate_search:next"
|
||
ghostty_surface_binding_action(surface, action, UInt(action.count))
|
||
}) {
|
||
Image(systemName: "chevron.up")
|
||
}
|
||
.buttonStyle(.borderless)
|
||
|
||
Button(action: {
|
||
guard let surface = surfaceView.surface else { return }
|
||
let action = "navigate_search:previous"
|
||
ghostty_surface_binding_action(surface, action, UInt(action.count))
|
||
}) {
|
||
Image(systemName: "chevron.down")
|
||
}
|
||
.buttonStyle(.borderless)
|
||
|
||
Button(action: {
|
||
searchState = nil
|
||
}) {
|
||
Image(systemName: "xmark")
|
||
}
|
||
.buttonStyle(.borderless)
|
||
}
|
||
.padding(8)
|
||
.background(.background)
|
||
.cornerRadius(8)
|
||
.shadow(radius: 4)
|
||
.onAppear {
|
||
isSearchFieldFocused = true
|
||
}
|
||
.onReceive(NotificationCenter.default.publisher(for: .ghosttySearchFocus)) { notification in
|
||
guard notification.object as? SurfaceView === surfaceView else { return }
|
||
isSearchFieldFocused = true
|
||
}
|
||
.background(
|
||
GeometryReader { barGeo in
|
||
Color.clear.onAppear {
|
||
barSize = barGeo.size
|
||
}
|
||
}
|
||
)
|
||
.padding(padding)
|
||
.offset(dragOffset)
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: corner.alignment)
|
||
.gesture(
|
||
DragGesture()
|
||
.onChanged { value in
|
||
dragOffset = value.translation
|
||
}
|
||
.onEnded { value in
|
||
let centerPos = centerPosition(for: corner, in: geo.size, barSize: barSize)
|
||
let newCenter = CGPoint(
|
||
x: centerPos.x + value.translation.width,
|
||
y: centerPos.y + value.translation.height
|
||
)
|
||
corner = closestCorner(to: newCenter, in: geo.size)
|
||
dragOffset = .zero
|
||
}
|
||
)
|
||
.animation(.easeOut(duration: 0.2), value: corner)
|
||
}
|
||
}
|
||
|
||
enum Corner {
|
||
case topLeft, topRight, bottomLeft, bottomRight
|
||
|
||
var alignment: Alignment {
|
||
switch self {
|
||
case .topLeft: return .topLeading
|
||
case .topRight: return .topTrailing
|
||
case .bottomLeft: return .bottomLeading
|
||
case .bottomRight: return .bottomTrailing
|
||
}
|
||
}
|
||
}
|
||
|
||
private func centerPosition(for corner: Corner, in containerSize: CGSize, barSize: CGSize) -> CGPoint {
|
||
let halfWidth = barSize.width / 2 + padding
|
||
let halfHeight = barSize.height / 2 + padding
|
||
|
||
switch corner {
|
||
case .topLeft:
|
||
return CGPoint(x: halfWidth, y: halfHeight)
|
||
case .topRight:
|
||
return CGPoint(x: containerSize.width - halfWidth, y: halfHeight)
|
||
case .bottomLeft:
|
||
return CGPoint(x: halfWidth, y: containerSize.height - halfHeight)
|
||
case .bottomRight:
|
||
return CGPoint(x: containerSize.width - halfWidth, y: containerSize.height - halfHeight)
|
||
}
|
||
}
|
||
|
||
private func closestCorner(to point: CGPoint, in containerSize: CGSize) -> Corner {
|
||
let midX = containerSize.width / 2
|
||
let midY = containerSize.height / 2
|
||
|
||
if point.x < midX {
|
||
return point.y < midY ? .topLeft : .bottomLeft
|
||
} else {
|
||
return point.y < midY ? .topRight : .bottomRight
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A surface is terminology in Ghostty for a terminal surface, or a place where a terminal is actually drawn
|
||
/// and interacted with. The word "surface" is used because a surface may represent a window, a tab,
|
||
/// a split, a small preview pane, etc. It is ANYTHING that has a terminal drawn to it.
|
||
struct SurfaceRepresentable: OSViewRepresentable {
|
||
/// The view to render for the terminal surface.
|
||
let view: SurfaceView
|
||
|
||
/// The size of the frame containing this view. We use this to update the the underlying
|
||
/// surface. This does not actually SET the size of our frame, this only sets the size
|
||
/// of our Metal surface for drawing.
|
||
///
|
||
/// Note: we do NOT use the NSView.resize function because SwiftUI on macOS 12
|
||
/// does not call this callback (macOS 13+ does).
|
||
///
|
||
/// The best approach is to wrap this view in a GeometryReader and pass in the geo.size.
|
||
let size: CGSize
|
||
|
||
#if canImport(AppKit)
|
||
func makeOSView(context: Context) -> SurfaceScrollView {
|
||
// On macOS, wrap the surface view in a scroll view
|
||
return SurfaceScrollView(contentSize: size, surfaceView: view)
|
||
}
|
||
|
||
func updateOSView(_ scrollView: SurfaceScrollView, context: Context) {
|
||
// Nothing to do: SwiftUI automatically updates the frame size, and
|
||
// SurfaceScrollView handles the rest in response to that
|
||
}
|
||
#else
|
||
func makeOSView(context: Context) -> SurfaceView {
|
||
// On iOS, return the surface view directly
|
||
return view
|
||
}
|
||
|
||
func updateOSView(_ view: SurfaceView, context: Context) {
|
||
view.sizeDidChange(size)
|
||
}
|
||
#endif
|
||
}
|
||
|
||
/// The configuration for a surface. For any configuration not set, defaults will be chosen from
|
||
/// libghostty, usually from the Ghostty configuration.
|
||
struct SurfaceConfiguration {
|
||
/// Explicit font size to use in points
|
||
var fontSize: Float32? = nil
|
||
|
||
/// Explicit working directory to set
|
||
var workingDirectory: String? = nil
|
||
|
||
/// Explicit command to set
|
||
var command: String? = nil
|
||
|
||
/// Environment variables to set for the terminal
|
||
var environmentVariables: [String: String] = [:]
|
||
|
||
/// Extra input to send as stdin
|
||
var initialInput: String? = nil
|
||
|
||
/// Wait after the command
|
||
var waitAfterCommand: Bool = false
|
||
|
||
init() {}
|
||
|
||
init(from config: ghostty_surface_config_s) {
|
||
self.fontSize = config.font_size
|
||
if let workingDirectory = config.working_directory {
|
||
self.workingDirectory = String.init(cString: workingDirectory, encoding: .utf8)
|
||
}
|
||
if let command = config.command {
|
||
self.command = String.init(cString: command, encoding: .utf8)
|
||
}
|
||
|
||
// Convert the C env vars to Swift dictionary
|
||
if config.env_var_count > 0, let envVars = config.env_vars {
|
||
for i in 0..<config.env_var_count {
|
||
let envVar = envVars[i]
|
||
if let key = String(cString: envVar.key, encoding: .utf8),
|
||
let value = String(cString: envVar.value, encoding: .utf8) {
|
||
self.environmentVariables[key] = value
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Provides a C-compatible ghostty configuration within a closure. The configuration
|
||
/// and all its string pointers are only valid within the closure.
|
||
func withCValue<T>(view: SurfaceView, _ body: (inout ghostty_surface_config_s) throws -> T) rethrows -> T {
|
||
var config = ghostty_surface_config_new()
|
||
config.userdata = Unmanaged.passUnretained(view).toOpaque()
|
||
#if os(macOS)
|
||
config.platform_tag = GHOSTTY_PLATFORM_MACOS
|
||
config.platform = ghostty_platform_u(macos: ghostty_platform_macos_s(
|
||
nsview: Unmanaged.passUnretained(view).toOpaque()
|
||
))
|
||
config.scale_factor = NSScreen.main!.backingScaleFactor
|
||
#elseif os(iOS)
|
||
config.platform_tag = GHOSTTY_PLATFORM_IOS
|
||
config.platform = ghostty_platform_u(ios: ghostty_platform_ios_s(
|
||
uiview: Unmanaged.passUnretained(view).toOpaque()
|
||
))
|
||
// Note that UIScreen.main is deprecated and we're supposed to get the
|
||
// screen through the view hierarchy instead. This means that we should
|
||
// probably set this to some default, then modify the scale factor through
|
||
// libghostty APIs when a UIView is attached to a window/scene. TODO.
|
||
config.scale_factor = UIScreen.main.scale
|
||
#else
|
||
#error("unsupported target")
|
||
#endif
|
||
|
||
// Zero is our default value that means to inherit the font size.
|
||
config.font_size = fontSize ?? 0
|
||
|
||
// Set wait after command
|
||
config.wait_after_command = waitAfterCommand
|
||
|
||
// Use withCString to ensure strings remain valid for the duration of the closure
|
||
return try workingDirectory.withCString { cWorkingDir in
|
||
config.working_directory = cWorkingDir
|
||
|
||
return try command.withCString { cCommand in
|
||
config.command = cCommand
|
||
|
||
return try initialInput.withCString { cInput in
|
||
config.initial_input = cInput
|
||
|
||
// Convert dictionary to arrays for easier processing
|
||
let keys = Array(environmentVariables.keys)
|
||
let values = Array(environmentVariables.values)
|
||
|
||
// Create C strings for all keys and values
|
||
return try keys.withCStrings { keyCStrings in
|
||
return try values.withCStrings { valueCStrings in
|
||
// Create array of ghostty_env_var_s
|
||
var envVars = Array<ghostty_env_var_s>()
|
||
envVars.reserveCapacity(environmentVariables.count)
|
||
for i in 0..<environmentVariables.count {
|
||
envVars.append(ghostty_env_var_s(
|
||
key: keyCStrings[i],
|
||
value: valueCStrings[i]
|
||
))
|
||
}
|
||
|
||
return try envVars.withUnsafeMutableBufferPointer { buffer in
|
||
config.env_vars = buffer.baseAddress
|
||
config.env_var_count = environmentVariables.count
|
||
return try body(&config)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Visual overlay that shows a border around the edges when the bell rings with border feature enabled.
|
||
struct BellBorderOverlay: View {
|
||
let bell: Bool
|
||
|
||
var body: some View {
|
||
Rectangle()
|
||
.strokeBorder(
|
||
Color(red: 1.0, green: 0.8, blue: 0.0).opacity(0.5),
|
||
lineWidth: 3
|
||
)
|
||
.allowsHitTesting(false)
|
||
.opacity(bell ? 1.0 : 0.0)
|
||
.animation(.easeInOut(duration: 0.3), value: bell)
|
||
}
|
||
}
|
||
|
||
#if canImport(AppKit)
|
||
/// When changing the split state, or going full screen (native or non), the terminal view
|
||
/// will lose focus. There has to be some nice SwiftUI-native way to fix this but I can't
|
||
/// figure it out so we're going to do this hacky thing to bring focus back to the terminal
|
||
/// that should have it.
|
||
static func moveFocus(
|
||
to: SurfaceView,
|
||
from: SurfaceView? = nil,
|
||
delay: TimeInterval? = nil
|
||
) {
|
||
// The whole delay machinery is a bit of a hack to work around a
|
||
// situation where the window is destroyed and the surface view
|
||
// will never be attached to a window. Realistically, we should
|
||
// handle this upstream but we also don't want this function to be
|
||
// a source of infinite loops.
|
||
|
||
// Our max delay before we give up
|
||
let maxDelay: TimeInterval = 0.5
|
||
guard (delay ?? 0) < maxDelay else { return }
|
||
|
||
// We start at a 50 millisecond delay and do a doubling backoff
|
||
let nextDelay: TimeInterval = if let delay {
|
||
delay * 2
|
||
} else {
|
||
// 100 milliseconds
|
||
0.05
|
||
}
|
||
|
||
let work: DispatchWorkItem = .init {
|
||
// If the callback runs before the surface is attached to a view
|
||
// then the window will be nil. We just reschedule in that case.
|
||
guard let window = to.window else {
|
||
moveFocus(to: to, from: from, delay: nextDelay)
|
||
return
|
||
}
|
||
|
||
// If we had a previously focused node and its not where we're sending
|
||
// focus, make sure that we explicitly tell it to lose focus. In theory
|
||
// we should NOT have to do this but the focus callback isn't getting
|
||
// called for some reason.
|
||
if let from = from {
|
||
_ = from.resignFirstResponder()
|
||
}
|
||
|
||
window.makeFirstResponder(to)
|
||
}
|
||
|
||
let queue = DispatchQueue.main
|
||
if let delay {
|
||
queue.asyncAfter(deadline: .now() + delay, execute: work)
|
||
} else {
|
||
queue.async(execute: work)
|
||
}
|
||
}
|
||
#endif
|
||
}
|
||
|
||
// MARK: Surface Environment Keys
|
||
|
||
private struct GhosttySurfaceViewKey: EnvironmentKey {
|
||
static let defaultValue: Ghostty.SurfaceView? = nil
|
||
}
|
||
|
||
extension EnvironmentValues {
|
||
var ghosttySurfaceView: Ghostty.SurfaceView? {
|
||
get { self[GhosttySurfaceViewKey.self] }
|
||
set { self[GhosttySurfaceViewKey.self] = newValue }
|
||
}
|
||
}
|
||
|
||
extension View {
|
||
func ghosttySurfaceView(_ surfaceView: Ghostty.SurfaceView?) -> some View {
|
||
environment(\.ghosttySurfaceView, surfaceView)
|
||
}
|
||
}
|
||
|
||
// MARK: Surface Focus Keys
|
||
|
||
extension FocusedValues {
|
||
var ghosttySurfaceView: Ghostty.SurfaceView? {
|
||
get { self[FocusedGhosttySurface.self] }
|
||
set { self[FocusedGhosttySurface.self] = newValue }
|
||
}
|
||
|
||
struct FocusedGhosttySurface: FocusedValueKey {
|
||
typealias Value = Ghostty.SurfaceView
|
||
}
|
||
|
||
var ghosttySurfacePwd: String? {
|
||
get { self[FocusedGhosttySurfacePwd.self] }
|
||
set { self[FocusedGhosttySurfacePwd.self] = newValue }
|
||
}
|
||
|
||
struct FocusedGhosttySurfacePwd: FocusedValueKey {
|
||
typealias Value = String
|
||
}
|
||
|
||
var ghosttySurfaceCellSize: OSSize? {
|
||
get { self[FocusedGhosttySurfaceCellSize.self] }
|
||
set { self[FocusedGhosttySurfaceCellSize.self] = newValue }
|
||
}
|
||
|
||
struct FocusedGhosttySurfaceCellSize: FocusedValueKey {
|
||
typealias Value = OSSize
|
||
}
|
||
}
|
||
|
||
// MARK: Search State
|
||
|
||
extension Ghostty.SurfaceView {
|
||
class SearchState: ObservableObject {
|
||
@Published var needle: String = ""
|
||
|
||
init(from startSearch: Ghostty.Action.StartSearch) {
|
||
self.needle = startSearch.needle ?? ""
|
||
}
|
||
}
|
||
}
|