Merge pull request #2129 from pnodet/patch-4

style(macos): cleanup trailing spaces
This commit is contained in:
Mitchell Hashimoto
2024-08-22 13:48:01 -04:00
committed by GitHub
34 changed files with 782 additions and 783 deletions

View File

@ -3,7 +3,7 @@ import SwiftUI
@main
struct Ghostty_iOSApp: App {
@StateObject private var ghostty_app = Ghostty.App()
var body: some Scene {
WindowGroup {
iOS_GhosttyTerminal()
@ -14,12 +14,12 @@ struct Ghostty_iOSApp: App {
struct iOS_GhosttyTerminal: View {
@EnvironmentObject private var ghostty_app: Ghostty.App
var body: some View {
ZStack {
// Make sure that our background color extends to all parts of the screen
Color(ghostty_app.config.backgroundColor).ignoresSafeArea()
Ghostty.Terminal()
}
}
@ -27,7 +27,7 @@ struct iOS_GhosttyTerminal: View {
struct iOS_GhosttyInitView: View {
@EnvironmentObject private var ghostty_app: Ghostty.App
var body: some View {
VStack {
Image("AppIconImage")

View File

@ -4,10 +4,10 @@ import OSLog
import Sparkle
import GhosttyKit
class AppDelegate: NSObject,
class AppDelegate: NSObject,
ObservableObject,
NSApplicationDelegate,
UNUserNotificationCenterDelegate,
UNUserNotificationCenterDelegate,
GhosttyAppDelegate
{
// The application logger. We should probably move this at some point to a dedicated
@ -16,14 +16,14 @@ class AppDelegate: NSObject,
subsystem: Bundle.main.bundleIdentifier!,
category: String(describing: AppDelegate.self)
)
/// Various menu items so that we can programmatically sync the keyboard shortcut with the Ghostty config
@IBOutlet private var menuServices: NSMenu?
@IBOutlet private var menuCheckForUpdates: NSMenuItem?
@IBOutlet private var menuOpenConfig: NSMenuItem?
@IBOutlet private var menuReloadConfig: NSMenuItem?
@IBOutlet private var menuQuit: NSMenuItem?
@IBOutlet private var menuNewWindow: NSMenuItem?
@IBOutlet private var menuNewTab: NSMenuItem?
@IBOutlet private var menuSplitRight: NSMenuItem?
@ -31,7 +31,7 @@ class AppDelegate: NSObject,
@IBOutlet private var menuClose: NSMenuItem?
@IBOutlet private var menuCloseWindow: NSMenuItem?
@IBOutlet private var menuCloseAllWindows: NSMenuItem?
@IBOutlet private var menuCopy: NSMenuItem?
@IBOutlet private var menuPaste: NSMenuItem?
@IBOutlet private var menuSelectAll: NSMenuItem?
@ -58,20 +58,20 @@ class AppDelegate: NSObject,
/// The dock menu
private var dockMenu: NSMenu = NSMenu()
/// This is only true before application has become active.
private var applicationHasBecomeActive: Bool = false
/// The ghostty global state. Only one per process.
let ghostty: Ghostty.App = Ghostty.App()
/// Manages our terminal windows.
let terminalManager: TerminalManager
/// Manages updates
let updaterController: SPUStandardUpdaterController
let updaterDelegate: UpdaterDelegate = UpdaterDelegate()
override init() {
terminalManager = TerminalManager(ghostty)
updaterController = SPUStandardUpdaterController(
@ -81,12 +81,12 @@ class AppDelegate: NSObject,
)
super.init()
ghostty.delegate = self
}
//MARK: - NSApplicationDelegate
func applicationWillFinishLaunching(_ notification: Notification) {
UserDefaults.standard.register(defaults: [
// Disable the automatic full screen menu item because we handle
@ -94,24 +94,24 @@ class AppDelegate: NSObject,
"NSFullScreenMenuItemEverywhere": false,
])
}
func applicationDidFinishLaunching(_ notification: Notification) {
// System settings overrides
UserDefaults.standard.register(defaults: [
// Disable this so that repeated key events make it through to our terminal views.
"ApplePressAndHoldEnabled": false,
])
// Hook up updater menu
menuCheckForUpdates?.target = updaterController
menuCheckForUpdates?.action = #selector(SPUStandardUpdaterController.checkForUpdates(_:))
// Initial config loading
configDidReload(ghostty)
// Register our service provider. This must happen after everything is initialized.
NSApp.servicesProvider = ServiceProvider()
// This registers the Ghostty => Services menu to exist.
NSApp.servicesMenu = menuServices
@ -135,7 +135,7 @@ class AppDelegate: NSObject,
func applicationDidBecomeActive(_ notification: Notification) {
guard !applicationHasBecomeActive else { return }
applicationHasBecomeActive = true
// Let's launch our first window. We only do this if we have no other windows. It
// is possible to have other windows in a few scenarios:
// - if we're opening a URL since `application(_:openFile:)` is called before this.
@ -152,39 +152,39 @@ class AppDelegate: NSObject,
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
let windows = NSApplication.shared.windows
if (windows.isEmpty) { return .terminateNow }
// This probably isn't fully safe. The isEmpty check above is aspirational, it doesn't
// quite work with SwiftUI because windows are retained on close. So instead we check
// if there are any that are visible. I'm guessing this breaks under certain scenarios.
if (windows.allSatisfy { !$0.isVisible }) { return .terminateNow }
// If the user is shutting down, restarting, or logging out, we don't confirm quit.
why: if let event = NSAppleEventManager.shared().currentAppleEvent {
// If all Ghostty windows are in the background (i.e. you Cmd-Q from the Cmd-Tab
// view), then this is null. I don't know why (pun intended) but we have to
// guard against it.
guard let keyword = AEKeyword("why?") else { break why }
if let why = event.attributeDescriptor(forKeyword: keyword) {
switch (why.typeCodeValue) {
case kAEShutDown:
fallthrough
case kAERestart:
fallthrough
case kAEReallyLogOut:
return .terminateNow
default:
break
}
}
}
// If our app says we don't need to confirm, we can exit now.
if (!ghostty.needsConfirmQuit) { return .terminateNow }
// We have some visible window. Show an app-wide modal to confirm quitting.
let alert = NSAlert()
alert.messageText = "Quit Ghostty?"
@ -195,31 +195,31 @@ class AppDelegate: NSObject,
switch (alert.runModal()) {
case .alertFirstButtonReturn:
return .terminateNow
default:
return .terminateCancel
}
}
/// This is called when the application is already open and someone double-clicks the icon
/// or clicks the dock icon.
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
// If we have visible windows then we allow macOS to do its default behavior
// of focusing one of them.
guard !flag else { return true }
// No visible windows, open a new one.
terminalManager.newWindow()
return false
}
func application(_ sender: NSApplication, openFile filename: String) -> Bool {
// Ghostty will validate as well but we can avoid creating an entirely new
// surface by doing our own validation here. We can also show a useful error
// this way.
var isDirectory = ObjCBool(true)
guard FileManager.default.fileExists(atPath: filename, isDirectory: &isDirectory) else { return false }
// Initialize the surface config which will be used to create the tab or window for the opened file.
var config = Ghostty.SurfaceConfiguration()
@ -238,20 +238,20 @@ class AppDelegate: NSObject,
return true
}
/// This is called for the dock right-click menu.
func applicationDockMenu(_ sender: NSApplication) -> NSMenu? {
return dockMenu
}
/// Sync all of our menu item keyboard shortcuts with the Ghostty configuration.
private func syncMenuShortcuts() {
guard ghostty.readiness == .ready else { return }
syncMenuShortcut(action: "open_config", menuItem: self.menuOpenConfig)
syncMenuShortcut(action: "reload_config", menuItem: self.menuReloadConfig)
syncMenuShortcut(action: "quit", menuItem: self.menuQuit)
syncMenuShortcut(action: "new_window", menuItem: self.menuNewWindow)
syncMenuShortcut(action: "new_tab", menuItem: self.menuNewTab)
syncMenuShortcut(action: "close_surface", menuItem: self.menuClose)
@ -259,11 +259,11 @@ class AppDelegate: NSObject,
syncMenuShortcut(action: "close_all_windows", menuItem: self.menuCloseAllWindows)
syncMenuShortcut(action: "new_split:right", menuItem: self.menuSplitRight)
syncMenuShortcut(action: "new_split:down", menuItem: self.menuSplitDown)
syncMenuShortcut(action: "copy_to_clipboard", menuItem: self.menuCopy)
syncMenuShortcut(action: "paste_from_clipboard", menuItem: self.menuPaste)
syncMenuShortcut(action: "select_all", menuItem: self.menuSelectAll)
syncMenuShortcut(action: "toggle_split_zoom", menuItem: self.menuZoomSplit)
syncMenuShortcut(action: "goto_split:previous", menuItem: self.menuPreviousSplit)
syncMenuShortcut(action: "goto_split:next", menuItem: self.menuNextSplit)
@ -281,7 +281,7 @@ class AppDelegate: NSObject,
syncMenuShortcut(action: "decrease_font_size:1", menuItem: self.menuDecreaseFontSize)
syncMenuShortcut(action: "reset_font_size", menuItem: self.menuResetFontSize)
syncMenuShortcut(action: "inspector:toggle", menuItem: self.menuTerminalInspector)
// This menu item is NOT synced with the configuration because it disables macOS
// global fullscreen keyboard shortcut. The shortcut in the Ghostty config will continue
// to work but it won't be reflected in the menu item.
@ -291,7 +291,7 @@ class AppDelegate: NSObject,
// Dock menu
reloadDockMenu()
}
/// Syncs a single menu shortcut for the given action. The action string is the same
/// action string used for the Ghostty configuration.
private func syncMenuShortcut(action: String, menuItem: NSMenuItem?) {
@ -302,17 +302,17 @@ class AppDelegate: NSObject,
menu.keyEquivalentModifierMask = []
return
}
menu.keyEquivalent = equiv.key
menu.keyEquivalentModifierMask = equiv.modifiers
}
private func focusedSurface() -> ghostty_surface_t? {
return terminalManager.focusedSurface?.surface
}
//MARK: - Restorable State
/// We support NSSecureCoding for restorable state. Required as of macOS Sonoma (14) but a good idea anyways.
func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool {
return true
@ -321,7 +321,7 @@ class AppDelegate: NSObject,
func application(_ app: NSApplication, willEncodeRestorableState coder: NSCoder) {
Self.logger.debug("application will save window state")
}
func application(_ app: NSApplication, didDecodeRestorableState coder: NSCoder) {
Self.logger.debug("application will restore window state")
}
@ -348,17 +348,17 @@ class AppDelegate: NSObject,
}
//MARK: - GhosttyAppDelegate
func findSurface(forUUID uuid: UUID) -> Ghostty.SurfaceView? {
for c in terminalManager.windows {
if let v = c.controller.surfaceTree?.findUUID(uuid: uuid) {
return v
}
}
return nil
}
func configDidReload(_ state: Ghostty.App) {
// Depending on the "window-save-state" setting we have to set the NSQuitAlwaysKeepsWindows
// configuration. This is the only way to carefully control whether macOS invokes the
@ -369,21 +369,21 @@ class AppDelegate: NSObject,
case "default": fallthrough
default: UserDefaults.standard.removeObject(forKey: "NSQuitAlwaysKeepsWindows")
}
// Config could change keybindings, so update everything that depends on that
syncMenuShortcuts()
terminalManager.relabelAllTabs()
// Config could change window appearance. We wrap this in an async queue because when
// this is called as part of application launch it can deadlock with an internal
// AppKit mutex on the appearance.
DispatchQueue.main.async { self.syncAppearance() }
// Update all of our windows
terminalManager.windows.forEach { window in
window.controller.configDidReload()
}
// If we have configuration errors, we need to show them.
let c = ConfigurationErrorsController.sharedInstance
c.errors = state.config.errors
@ -393,7 +393,7 @@ class AppDelegate: NSObject,
}
}
}
/// Sync the appearance of our app with the theme specified in the config.
private func syncAppearance() {
guard let theme = ghostty.config.windowTheme else { return }
@ -401,67 +401,67 @@ class AppDelegate: NSObject,
case "dark":
let appearance = NSAppearance(named: .darkAqua)
NSApplication.shared.appearance = appearance
case "light":
let appearance = NSAppearance(named: .aqua)
NSApplication.shared.appearance = appearance
case "auto":
let color = OSColor(ghostty.config.backgroundColor)
let appearance = NSAppearance(named: color.isLightColor ? .aqua : .darkAqua)
NSApplication.shared.appearance = appearance
default:
NSApplication.shared.appearance = nil
}
}
//MARK: - Dock Menu
private func reloadDockMenu() {
let newWindow = NSMenuItem(title: "New Window", action: #selector(newWindow), keyEquivalent: "")
let newTab = NSMenuItem(title: "New Tab", action: #selector(newTab), keyEquivalent: "")
dockMenu.removeAllItems()
dockMenu.addItem(newWindow)
dockMenu.addItem(newTab)
}
//MARK: - IB Actions
@IBAction func openConfig(_ sender: Any?) {
ghostty.openConfig()
}
@IBAction func reloadConfig(_ sender: Any?) {
ghostty.reloadConfig()
}
@IBAction func newWindow(_ sender: Any?) {
terminalManager.newWindow()
// We also activate our app so that it becomes front. This may be
// necessary for the dock menu.
NSApp.activate(ignoringOtherApps: true)
}
@IBAction func newTab(_ sender: Any?) {
terminalManager.newTab()
// We also activate our app so that it becomes front. This may be
// necessary for the dock menu.
NSApp.activate(ignoringOtherApps: true)
}
@IBAction func closeAllWindows(_ sender: Any?) {
terminalManager.closeAllWindows()
AboutController.shared.hide()
}
@IBAction func showAbout(_ sender: Any?) {
AboutController.shared.show()
}
@IBAction func showHelp(_ sender: Any) {
guard let url = URL(string: "https://github.com/ghostty-org/ghostty") else { return }
NSWorkspace.shared.open(url)

View File

@ -4,35 +4,35 @@ import SwiftUI
class AboutController: NSWindowController, NSWindowDelegate {
static let shared: AboutController = AboutController()
override var windowNibName: NSNib.Name? { "About" }
override func windowDidLoad() {
guard let window = window else { return }
window.center()
window.contentView = NSHostingView(rootView: AboutView())
}
// MARK: - Functions
func show() {
window?.makeKeyAndOrderFront(nil)
}
func hide() {
window?.close()
}
//MARK: - First Responder
@IBAction func close(_ sender: Any) {
self.window?.performClose(sender)
}
@IBAction func closeWindow(_ sender: Any) {
self.window?.performClose(sender)
}
// This is called when "escape" is pressed.
@objc func cancel(_ sender: Any?) {
close()

View File

@ -5,24 +5,24 @@ struct AboutView: View {
var build: String? { Bundle.main.infoDictionary?["CFBundleVersion"] as? String }
var commit: String? { Bundle.main.infoDictionary?["GhosttyCommit"] as? String }
var version: String? { Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String }
var body: some View {
VStack(alignment: .center) {
Image("AppIconImage")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxHeight: 96)
Text("Ghostty")
.font(.title3)
.textSelection(.enabled)
if let version = self.version {
Text("Version: \(version)")
.font(.body)
.textSelection(.enabled)
}
if let build = self.build {
Text("Build: \(build)")
.font(.body)

View File

@ -3,13 +3,13 @@ import AppKit
class ServiceProvider: NSObject {
static private let errorNoString = NSString(string: "Could not load any text from the clipboard.")
/// The target for an open operation
enum OpenTarget {
case tab
case window
}
@objc func openTab(
_ pasteboard: NSPasteboard,
userData: String?,
@ -17,7 +17,7 @@ class ServiceProvider: NSObject {
) {
openTerminalFromPasteboard(pasteboard: pasteboard, target: .tab, error: error)
}
@objc func openWindow(
_ pasteboard: NSPasteboard,
userData: String?,
@ -37,10 +37,10 @@ class ServiceProvider: NSObject {
return
}
let filePaths = objs.map { $0.path }.compactMap { $0 }
openTerminal(filePaths, target: target)
}
private func openTerminal(_ paths: [String], target: OpenTarget) {
guard let delegateRaw = NSApp.delegate else { return }
guard let delegate = delegateRaw as? AppDelegate else { return }
@ -51,7 +51,7 @@ class ServiceProvider: NSObject {
var isDirectory = ObjCBool(true)
guard FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) else { continue }
guard isDirectory.boolValue else { continue }
// Build our config
var config = Ghostty.SurfaceConfiguration()
config.workingDirectory = path

View File

@ -6,9 +6,9 @@ import Combine
class ConfigurationErrorsController: NSWindowController, NSWindowDelegate, ConfigurationErrorsViewModel {
/// Singleton for the errors view.
static let sharedInstance = ConfigurationErrorsController()
override var windowNibName: NSNib.Name? { "ConfigurationErrors" }
/// The data model for this view. Update this directly and the associated view will be updated, too.
@Published var errors: [String] = [] {
didSet {
@ -17,13 +17,13 @@ class ConfigurationErrorsController: NSWindowController, NSWindowDelegate, Confi
}
}
}
//MARK: - NSWindowController
override func windowWillLoad() {
shouldCascadeWindows = false
}
override func windowDidLoad() {
guard let window = window else { return }
window.center()

View File

@ -6,7 +6,7 @@ protocol ConfigurationErrorsViewModel: ObservableObject {
struct ConfigurationErrorsView<ViewModel: ConfigurationErrorsViewModel>: View {
@ObservedObject var model: ViewModel
var body: some View {
VStack {
HStack {
@ -15,7 +15,7 @@ struct ConfigurationErrorsView<ViewModel: ConfigurationErrorsViewModel>: View {
.font(.system(size: 52))
.padding()
.frame(alignment: .center)
Text("""
^[\(model.errors.count) error(s) were](inflect: true) found while loading the configuration. \
Please review the errors below and reload your configuration or ignore the erroneous lines.
@ -34,7 +34,7 @@ struct ConfigurationErrorsView<ViewModel: ConfigurationErrorsViewModel>: View {
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .topLeading)
}
Spacer()
}
.padding(.all)
@ -42,7 +42,7 @@ struct ConfigurationErrorsView<ViewModel: ConfigurationErrorsViewModel>: View {
.background(Color(.controlBackgroundColor))
}
}
HStack {
Spacer()
Button("Ignore") { model.errors = [] }
@ -52,7 +52,7 @@ struct ConfigurationErrorsView<ViewModel: ConfigurationErrorsViewModel>: View {
}
.frame(minWidth: 480, maxWidth: 960, minHeight: 270)
}
private func reloadConfig() {
guard let delegate = NSApplication.shared.delegate as? AppDelegate else { return }
delegate.reloadConfig(nil)

View File

@ -3,14 +3,14 @@ import SwiftUI
struct SettingsView: View {
// We need access to our app delegate to know if we're quitting or not.
@EnvironmentObject private var appDelegate: AppDelegate
var body: some View {
HStack {
Image("AppIconImage")
.resizable()
.scaledToFit()
.frame(width: 128, height: 128)
VStack(alignment: .leading) {
Text("Coming Soon. 🚧").font(.title)
Text("You can't configure settings in the GUI yet. To modify settings, " +

View File

@ -7,7 +7,7 @@ struct ErrorView: View {
.resizable()
.scaledToFit()
.frame(width: 128, height: 128)
VStack(alignment: .leading) {
Text("Oh, no. 😭").font(.title)
Text("Something went fatally wrong.\nCheck the logs and restart Ghostty.")

View File

@ -4,22 +4,22 @@ import SwiftUI
import GhosttyKit
/// The terminal controller is an NSWindowController that maps 1:1 to a terminal window.
class TerminalController: NSWindowController, NSWindowDelegate,
class TerminalController: NSWindowController, NSWindowDelegate,
TerminalViewDelegate, TerminalViewModel,
ClipboardConfirmationViewDelegate
{
override var windowNibName: NSNib.Name? { "Terminal" }
/// The app instance that this terminal view will represent.
let ghostty: Ghostty.App
/// The currently focused surface.
var focusedSurface: Ghostty.SurfaceView? = nil {
didSet {
syncFocusToSurfaceTree()
}
}
/// The surface tree for this window.
@Published var surfaceTree: Ghostty.SplitNode? = nil {
didSet {
@ -32,25 +32,25 @@ class TerminalController: NSWindowController, NSWindowDelegate,
}
}
}
/// Fullscreen state management.
let fullscreenHandler = FullScreenHandler()
/// True when an alert is active so we don't overlap multiple.
private var alert: NSAlert? = nil
/// The clipboard confirmation window, if shown.
private var clipboardConfirmation: ClipboardConfirmationController? = nil
/// This is set to true when we care about frame changes. This is a small optimization since
/// this controller registers a listener for ALL frame change notifications and this lets us bail
/// early if we don't care.
private var tabListenForFrame: Bool = false
/// This is the hash value of the last tabGroup.windows array. We use this to detect order
/// changes in the list.
private var tabWindowsHash: Int = 0
/// This is set to false by init if the window managed by this controller should not be restorable.
/// For example, terminals executing custom scripts are not restorable.
private var restorable: Bool = true
@ -60,20 +60,20 @@ class TerminalController: NSWindowController, NSWindowDelegate,
withSurfaceTree tree: Ghostty.SplitNode? = nil
) {
self.ghostty = ghostty
// The window we manage is not restorable if we've specified a command
// to execute. We do this because the restored window is meaningless at the
// time of writing this: it'd just restore to a shell in the same directory
// as the script. We may want to revisit this behavior when we have scrollback
// restoration.
self.restorable = (base?.command ?? "") == ""
super.init(window: nil)
// Initialize our initial surface.
guard let ghostty_app = ghostty.app else { preconditionFailure("app must be loaded") }
self.surfaceTree = tree ?? .leaf(.init(ghostty_app, baseConfig: base))
// Setup our notifications for behaviors
let center = NotificationCenter.default
center.addObserver(
@ -97,25 +97,25 @@ class TerminalController: NSWindowController, NSWindowDelegate,
name: NSView.frameDidChangeNotification,
object: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported for this view")
}
deinit {
// Remove all of our notificationcenter subscriptions
let center = NotificationCenter.default
center.removeObserver(self)
}
//MARK: - Methods
func configDidReload() {
guard let window = window as? TerminalWindow else { return }
window.focusFollowsMouse = ghostty.config.focusFollowsMouse
syncAppearance()
}
/// Update the accessory view of each tab according to the keyboard
/// shortcut that activates it (if any). This is called when the key window
/// changes, when a window is closed, and when tabs are reordered
@ -129,7 +129,7 @@ class TerminalController: NSWindowController, NSWindowDelegate,
// We only listen for frame changes if we have more than 1 window,
// otherwise the accessory view doesn't matter.
tabListenForFrame = windows.count > 1
for (tab, window) in zip(1..., windows) {
// We need to clear any windows beyond this because they have had
// a keyEquivalent set previously.
@ -158,7 +158,7 @@ class TerminalController: NSWindowController, NSWindowDelegate,
window.isOpaque = false
}
}
@objc private func onFrameDidChange(_ notification: NSNotification) {
// This is a huge hack to set the proper shortcut for tab selection
// on tab reordering using the mouse. There is no event, delegate, etc.
@ -173,10 +173,10 @@ class TerminalController: NSWindowController, NSWindowDelegate,
tabWindowsHash = v
self.relabelTabs()
}
private func syncAppearance() {
guard let window = self.window as? TerminalWindow else { return }
// If our window is not visible, then delay this. This is possible specifically
// during state restoration but probably in other scenarios as well. To delay,
// we just loop directly on the dispatch queue. We have to delay because some
@ -186,14 +186,14 @@ class TerminalController: NSWindowController, NSWindowDelegate,
DispatchQueue.main.async { [weak self] in self?.syncAppearance() }
return
}
// Set the font for the window and tab titles.
if let titleFontName = ghostty.config.windowTitleFontFamily {
window.titlebarFont = NSFont(name: titleFontName, size: NSFont.systemFontSize)
} else {
window.titlebarFont = nil
}
// If we have window transparency then set it transparent. Otherwise set it opaque.
if (ghostty.config.backgroundOpacity < 1) {
window.isOpaque = false
@ -202,7 +202,7 @@ class TerminalController: NSWindowController, NSWindowDelegate,
// matches Terminal.app much more closer. This lets users transition from
// Terminal.app more easily.
window.backgroundColor = .white.withAlphaComponent(0.001)
ghostty_set_window_background_blur(ghostty.app, Unmanaged.passUnretained(window).toOpaque())
} else {
window.isOpaque = true
@ -217,19 +217,19 @@ class TerminalController: NSWindowController, NSWindowDelegate,
// because we handle it here.
let backgroundColor = OSColor(ghostty.config.backgroundColor)
window.titlebarColor = backgroundColor.withAlphaComponent(ghostty.config.backgroundOpacity)
if (window.isOpaque) {
// Bg color is only synced if we have no transparency. This is because
// the transparency is handled at the surface level (window.backgroundColor
// ignores alpha components)
window.backgroundColor = backgroundColor
// If there is transparency, calling this will make the titlebar opaque
// so we only call this if we are opaque.
window.updateTabBar()
}
}
/// Update all surfaces with the focus state. This ensures that libghostty has an accurate view about
/// what surface is focused. This must be called whenever a surface OR window changes focus.
private func syncFocusToSurfaceTree() {
@ -246,25 +246,25 @@ class TerminalController: NSWindowController, NSWindowDelegate,
}
//MARK: - NSWindowController
override func windowWillLoad() {
// We do NOT want to cascade because we handle this manually from the manager.
shouldCascadeWindows = false
}
override func windowDidLoad() {
guard let window = window as? TerminalWindow else { return }
// Setting all three of these is required for restoration to work.
window.isRestorable = restorable
if (restorable) {
window.restorationClass = TerminalWindowRestoration.self
window.identifier = .init(String(describing: TerminalWindowRestoration.self))
}
// If window decorations are disabled, remove our title
if (!ghostty.config.windowDecorations) { window.styleMask.remove(.titled) }
// Terminals typically operate in sRGB color space and macOS defaults
// to "native" which is typically P3. There is a lot more resources
// covered in this GitHub issue: https://github.com/mitchellh/ghostty/pull/376
@ -277,7 +277,7 @@ class TerminalController: NSWindowController, NSWindowDelegate,
default:
window.colorSpace = .sRGB
}
// If we have only a single surface (no splits) and that surface requested
// an initial size then we set it here now.
if case let .leaf(leaf) = surfaceTree {
@ -289,12 +289,12 @@ class TerminalController: NSWindowController, NSWindowDelegate,
frame.size.height -= leaf.surface.frame.size.height
frame.size.width += initialSize.width
frame.size.height += initialSize.height
// We have no tabs and we are not a split, so set the initial size of the window.
window.setFrame(frame, display: true)
}
}
// Center the window to start, we'll move the window frame automatically
// when cascading.
window.center()
@ -316,7 +316,7 @@ class TerminalController: NSWindowController, NSWindowDelegate,
} else if (ghostty.config.macosTitlebarStyle == "transparent") {
window.transparentTabs = true
}
if window.hasStyledTabs {
// Set the background color of the window
let backgroundColor = NSColor(ghostty.config.backgroundColor)
@ -332,7 +332,7 @@ class TerminalController: NSWindowController, NSWindowDelegate,
viewModel: self,
delegate: self
))
// In various situations, macOS automatically tabs new windows. Ghostty handles
// its own tabbing so we DONT want this behavior. This detects this scenario and undoes
// it.
@ -358,7 +358,7 @@ class TerminalController: NSWindowController, NSWindowDelegate,
// Apply any additional appearance-related properties to the new window.
syncAppearance()
}
// Shows the "+" button in the tab bar, responds to that click.
override func newWindowForTab(_ sender: Any?) {
// Trigger the ghostty core event logic for a new tab.
@ -367,23 +367,23 @@ class TerminalController: NSWindowController, NSWindowDelegate,
}
//MARK: - NSWindowDelegate
// This is called when performClose is called on a window (NOT when close()
// is called directly). performClose is called primarily when UI elements such
// as the "red X" are pressed.
func windowShouldClose(_ sender: NSWindow) -> Bool {
// We must have a window. Is it even possible not to?
guard let window = self.window else { return true }
// If we have no surfaces, close.
guard let node = self.surfaceTree else { return true }
// If we already have an alert, continue with it
guard alert == nil else { return false }
// If our surfaces don't require confirmation, close.
if (!node.needsConfirmQuit()) { return true }
// We require confirmation, so show an alert as long as we aren't already.
let alert = NSAlert()
alert.messageText = "Close Terminal?"
@ -397,45 +397,45 @@ class TerminalController: NSWindowController, NSWindowDelegate,
switch (response) {
case .alertFirstButtonReturn:
window.close()
default:
break
}
})
self.alert = alert
return false
}
func windowWillClose(_ notification: Notification) {
// I don't know if this is required anymore. We previously had a ref cycle between
// the view and the window so we had to nil this out to break it but I think this
// may now be resolved. We should verify that no memory leaks and we can remove this.
self.window?.contentView = nil
self.relabelTabs()
}
func windowDidBecomeKey(_ notification: Notification) {
self.relabelTabs()
self.fixTabBar()
// Becoming/losing key means we have to notify our surface(s) that we have focus
// so things like cursors blink, pty events are sent, etc.
self.syncFocusToSurfaceTree()
}
func windowDidResignKey(_ notification: Notification) {
// Becoming/losing key means we have to notify our surface(s) that we have focus
// so things like cursors blink, pty events are sent, etc.
self.syncFocusToSurfaceTree()
}
func windowDidMove(_ notification: Notification) {
self.fixTabBar()
}
func windowDidChangeOcclusionState(_ notification: Notification) {
guard let surfaceTree = self.surfaceTree else { return }
let visible = self.window?.occlusionState.contains(.visible) ?? false
@ -452,24 +452,24 @@ class TerminalController: NSWindowController, NSWindowDelegate,
let data = TerminalRestorableState(from: self)
data.encode(with: state)
}
//MARK: - First Responder
@IBAction func newWindow(_ sender: Any?) {
guard let surface = focusedSurface?.surface else { return }
ghostty.newWindow(surface: surface)
}
@IBAction func newTab(_ sender: Any?) {
guard let surface = focusedSurface?.surface else { return }
ghostty.newTab(surface: surface)
}
@IBAction func close(_ sender: Any) {
guard let surface = focusedSurface?.surface else { return }
ghostty.requestClose(surface: surface)
}
@IBAction func closeWindow(_ sender: Any) {
guard let window = window else { return }
guard let tabGroup = window.tabGroup else {
@ -477,13 +477,13 @@ class TerminalController: NSWindowController, NSWindowDelegate,
window.performClose(sender)
return
}
// If have one window then we just do a normal close
if tabGroup.windows.count == 1 {
window.performClose(sender)
return
}
// Check if any windows require close confirmation.
var needsConfirm: Bool = false
for tabWindow in tabGroup.windows {
@ -493,16 +493,16 @@ class TerminalController: NSWindowController, NSWindowDelegate,
break
}
}
// If none need confirmation then we can just close all the windows.
if (!needsConfirm) {
for tabWindow in tabGroup.windows {
tabWindow.close()
}
return
}
// If we need confirmation by any, show one confirmation for all windows
// in the tab group.
let alert = NSAlert()
@ -519,42 +519,42 @@ class TerminalController: NSWindowController, NSWindowDelegate,
}
})
}
@IBAction func splitRight(_ sender: Any) {
guard let surface = focusedSurface?.surface else { return }
ghostty.split(surface: surface, direction: GHOSTTY_SPLIT_RIGHT)
}
@IBAction func splitDown(_ sender: Any) {
guard let surface = focusedSurface?.surface else { return }
ghostty.split(surface: surface, direction: GHOSTTY_SPLIT_DOWN)
}
@IBAction func splitZoom(_ sender: Any) {
guard let surface = focusedSurface?.surface else { return }
ghostty.splitToggleZoom(surface: surface)
}
@IBAction func splitMoveFocusPrevious(_ sender: Any) {
splitMoveFocus(direction: .previous)
}
@IBAction func splitMoveFocusNext(_ sender: Any) {
splitMoveFocus(direction: .next)
}
@IBAction func splitMoveFocusAbove(_ sender: Any) {
splitMoveFocus(direction: .top)
}
@IBAction func splitMoveFocusBelow(_ sender: Any) {
splitMoveFocus(direction: .bottom)
}
@IBAction func splitMoveFocusLeft(_ sender: Any) {
splitMoveFocus(direction: .left)
}
@IBAction func splitMoveFocusRight(_ sender: Any) {
splitMoveFocus(direction: .right)
}
@ -588,12 +588,12 @@ class TerminalController: NSWindowController, NSWindowDelegate,
guard let surface = focusedSurface?.surface else { return }
ghostty.splitMoveFocus(surface: surface, direction: direction)
}
@IBAction func toggleGhosttyFullScreen(_ sender: Any) {
guard let surface = focusedSurface?.surface else { return }
ghostty.toggleFullscreen(surface: surface)
}
@IBAction func increaseFontSize(_ sender: Any) {
guard let surface = focusedSurface?.surface else { return }
ghostty.changeFontSize(surface: surface, .increase(1))
@ -608,44 +608,44 @@ class TerminalController: NSWindowController, NSWindowDelegate,
guard let surface = focusedSurface?.surface else { return }
ghostty.changeFontSize(surface: surface, .reset)
}
@IBAction func toggleTerminalInspector(_ sender: Any) {
guard let surface = focusedSurface?.surface else { return }
ghostty.toggleTerminalInspector(surface: surface)
}
@objc func resetTerminal(_ sender: Any) {
guard let surface = focusedSurface?.surface else { return }
ghostty.resetTerminal(surface: surface)
}
//MARK: - TerminalViewDelegate
func focusedSurfaceDidChange(to: Ghostty.SurfaceView?) {
self.focusedSurface = to
}
func titleDidChange(to: String) {
guard let window = window as? TerminalWindow else { return }
// Set the main window title
window.title = to
// Custom toolbar-based title used when titlebar tabs are enabled.
if let toolbar = window.toolbar as? TerminalToolbar {
toolbar.titleText = to
}
}
func cellSizeDidChange(to: NSSize) {
guard ghostty.config.windowStepResize else { return }
self.window?.contentResizeIncrements = to
}
func lastSurfaceDidClose() {
self.window?.close()
}
func surfaceTreeDidChange() {
// Whenever our surface tree changes in any way (new split, close split, etc.)
// we want to invalidate our state.
@ -658,7 +658,7 @@ class TerminalController: NSWindowController, NSWindowDelegate,
}
//MARK: - Clipboard Confirmation
func clipboardConfirmationComplete(_ action: ClipboardConfirmationView.Action, _ request: Ghostty.ClipboardRequest) {
// End our clipboard confirmation no matter what
guard let cc = self.clipboardConfirmation else { return }
@ -688,30 +688,30 @@ class TerminalController: NSWindowController, NSWindowDelegate,
Ghostty.App.completeClipboardRequest(cc.surface, data: str, state: cc.state, confirmed: true)
}
}
//MARK: - Notifications
@objc private func onGotoTab(notification: SwiftUI.Notification) {
guard let target = notification.object as? Ghostty.SurfaceView else { return }
guard target == self.focusedSurface else { return }
guard let window = self.window else { return }
// Get the tab index from the notification
guard let tabIndexAny = notification.userInfo?[Ghostty.Notification.GotoTabKey] else { return }
guard let tabIndex = tabIndexAny as? Int32 else { return }
guard let windowController = window.windowController else { return }
guard let tabGroup = windowController.window?.tabGroup else { return }
let tabbedWindows = tabGroup.windows
// This will be the index we want to actual go to
let finalIndex: Int
// An index that is invalid is used to signal some special values.
if (tabIndex <= 0) {
guard let selectedWindow = tabGroup.selectedWindow else { return }
guard let selectedIndex = tabbedWindows.firstIndex(where: { $0 == selectedWindow }) else { return }
if (tabIndex == GHOSTTY_TAB_PREVIOUS.rawValue) {
if (selectedIndex == 0) {
finalIndex = tabbedWindows.count - 1
@ -731,51 +731,51 @@ class TerminalController: NSWindowController, NSWindowDelegate,
// Tabs are 0-indexed here, so we subtract one from the key the user hit.
finalIndex = Int(tabIndex - 1)
}
guard finalIndex >= 0 && finalIndex < tabbedWindows.count else { return }
let targetWindow = tabbedWindows[finalIndex]
targetWindow.makeKeyAndOrderFront(nil)
}
@objc private func onToggleFullscreen(notification: SwiftUI.Notification) {
guard let target = notification.object as? Ghostty.SurfaceView else { return }
guard target == self.focusedSurface else { return }
// We need a window to fullscreen
guard let window = self.window else { return }
// Check whether we use non-native fullscreen
guard let useNonNativeFullscreenAny = notification.userInfo?[Ghostty.Notification.NonNativeFullscreenKey] else { return }
guard let useNonNativeFullscreen = useNonNativeFullscreenAny as? ghostty_non_native_fullscreen_e else { return }
self.fullscreenHandler.toggleFullscreen(window: window, nonNativeFullscreen: useNonNativeFullscreen)
// For some reason focus always gets lost when we toggle fullscreen, so we set it back.
if let focusedSurface {
Ghostty.moveFocus(to: focusedSurface)
}
}
@objc private func onConfirmClipboardRequest(notification: SwiftUI.Notification) {
guard let target = notification.object as? Ghostty.SurfaceView else { return }
guard target == self.focusedSurface else { return }
guard let surface = target.surface else { return }
// We need a window
guard let window = self.window else { return }
// Check whether we use non-native fullscreen
guard let str = notification.userInfo?[Ghostty.Notification.ConfirmClipboardStrKey] as? String else { return }
guard let state = notification.userInfo?[Ghostty.Notification.ConfirmClipboardStateKey] as? UnsafeMutableRawPointer? else { return }
guard let request = notification.userInfo?[Ghostty.Notification.ConfirmClipboardRequestKey] as? Ghostty.ClipboardRequest else { return }
// If we already have a clipboard confirmation view up, we ignore this request.
// This shouldn't be possible...
guard self.clipboardConfirmation == nil else {
Ghostty.App.completeClipboardRequest(surface, data: "", state: state, confirmed: true)
return
}
// Show our paste confirmation
self.clipboardConfirmation = ClipboardConfirmationController(
surface: surface,

View File

@ -10,20 +10,20 @@ class TerminalManager {
let controller: TerminalController
let closePublisher: AnyCancellable
}
let ghostty: Ghostty.App
/// The currently focused surface of the main window.
var focusedSurface: Ghostty.SurfaceView? { mainWindow?.controller.focusedSurface }
/// The set of windows we currently have.
var windows: [Window] = []
// Keep track of the last point that our window was launched at so that new
// windows "cascade" over each other and don't just launch directly on top
// of each other.
private static var lastCascadePoint = NSPoint(x: 0, y: 0)
/// Returns the main window of the managed window stack. If there is no window
/// then an arbitrary window will be chosen.
private var mainWindow: Window? {
@ -32,14 +32,14 @@ class TerminalManager {
return window
}
}
// If we have no main window, just use the last window.
return windows.last
}
init(_ ghostty: Ghostty.App) {
self.ghostty = ghostty
let center = NotificationCenter.default
center.addObserver(
self,
@ -52,32 +52,32 @@ class TerminalManager {
name: Ghostty.Notification.ghosttyNewWindow,
object: nil)
}
deinit {
let center = NotificationCenter.default
center.removeObserver(self)
}
// MARK: - Window Management
/// Create a new terminal window.
func newWindow(withBaseConfig base: Ghostty.SurfaceConfiguration? = nil) {
let c = createWindow(withBaseConfig: base)
let window = c.window!
// We want to go fullscreen if we're configured for new windows to go fullscreen
var toggleFullScreen = ghostty.config.windowFullscreen
// If the previous focused window prior to creating this window is fullscreen,
// then this window also becomes fullscreen.
if let parent = focusedSurface?.window, parent.styleMask.contains(.fullScreen) {
toggleFullScreen = true
}
if (toggleFullScreen && !window.styleMask.contains(.fullScreen)) {
window.toggleFullScreen(nil)
}
// We're dispatching this async because otherwise the lastCascadePoint doesn't
// take effect. Our best theory is there is some next-event-loop-tick logic
// that Cocoa is doing that we need to be after.
@ -86,11 +86,11 @@ class TerminalManager {
if (!window.styleMask.contains(.fullScreen)) {
Self.lastCascadePoint = window.cascadeTopLeft(from: Self.lastCascadePoint)
}
c.showWindow(self)
}
}
/// Creates a new tab in the current main window. If there are no windows, a window
/// is created.
func newTab(withBaseConfig base: Ghostty.SurfaceConfiguration? = nil) {
@ -99,11 +99,11 @@ class TerminalManager {
newWindow(withBaseConfig: base)
return
}
// Create a new window and add it to the parent
newTab(to: parent, withBaseConfig: base)
}
private func newTab(to parent: NSWindow, withBaseConfig base: Ghostty.SurfaceConfiguration?) {
// If our parent is in non-native fullscreen, then new tabs do not work.
// See: https://github.com/mitchellh/ghostty/issues/392
@ -117,15 +117,15 @@ class TerminalManager {
alert.beginSheetModal(for: parent)
return
}
// Create a new window and add it to the parent
let controller = createWindow(withBaseConfig: base)
let window = controller.window!
// If the parent is miniaturized, then macOS exhibits really strange behaviors
// so we have to bring it back out.
if (parent.isMiniaturized) { parent.deminiaturize(self) }
// If our parent tab group already has this window, macOS added it and
// we need to remove it so we can set the correct order in the next line.
// If we don't do this, macOS gets really confused and the tabbedWindows
@ -136,12 +136,12 @@ class TerminalManager {
if let tg = parent.tabGroup, tg.windows.firstIndex(of: window) != nil {
tg.removeWindow(window)
}
// Our windows start out invisible. We need to make it visible. If we
// don't do this then various features such as window blur won't work because
// the macOS APIs only work on a visible window.
controller.showWindow(self)
// Add the window to the tab group and show it.
switch ghostty.config.windowNewTabPosition {
case "end":
@ -158,14 +158,14 @@ class TerminalManager {
}
window.makeKeyAndOrderFront(self)
// It takes an event loop cycle until the macOS tabGroup state becomes
// consistent which causes our tab labeling to be off when the "+" button
// is used in the tab bar. This fixes that. If we can find a more robust
// solution we should do that.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { controller.relabelTabs() }
}
/// Creates a window controller, adds it to our managed list, and returns it.
func createWindow(withBaseConfig base: Ghostty.SurfaceConfiguration? = nil,
withSurfaceTree tree: Ghostty.SplitNode? = nil) -> TerminalController {
@ -181,25 +181,25 @@ class TerminalManager {
guard let c = window.windowController as? TerminalController else { return }
self.removeWindow(c)
}
// Keep track of every window we manage
windows.append(Window(
controller: c,
closePublisher: pubClose
))
return c
}
func removeWindow(_ controller: TerminalController) {
// Remove it from our managed set
guard let idx = self.windows.firstIndex(where: { $0.controller == controller }) else { return }
let w = self.windows[idx]
self.windows.remove(at: idx)
// Ensure any publishers we have are cancelled
w.closePublisher.cancel()
// If we remove a window, we reset the cascade point to the key window so that
// the next window cascade's from that one.
if let focusedWindow = NSApplication.shared.keyWindow {
@ -210,19 +210,19 @@ class TerminalManager {
Self.lastCascadePoint = focusedWindow.cascadeTopLeft(from: NSZeroPoint)
return
}
// If we are the focused window, then we set the last cascade point to
// our own frame so that it shows up in the same spot.
let frame = focusedWindow.frame
Self.lastCascadePoint = NSPoint(x: frame.minX, y: frame.maxY)
}
// I don't think we strictly have to do this but if a window is
// closed I want to make sure that the app state is invalided so
// we don't reopen closed windows.
NSApplication.shared.invalidateRestorableState()
}
/// Close all windows, asking for confirmation if necessary.
func closeAllWindows() {
var needsConfirm: Bool = false
@ -232,15 +232,15 @@ class TerminalManager {
break
}
}
if (!needsConfirm) {
for w in self.windows {
w.controller.close()
}
return
}
// If we don't have a main window, we just close all windows because
// we have no window to show the modal on top of. I'm sure there's a way
// to do an app-level alert but I don't know how and this case should never
@ -249,10 +249,10 @@ class TerminalManager {
for w in self.windows {
w.controller.close()
}
return
}
// If we need confirmation by any, show one confirmation for all windows
let alert = NSAlert()
alert.messageText = "Close All Windows?"
@ -268,29 +268,29 @@ class TerminalManager {
}
})
}
/// Relabels all the tabs with the proper keyboard shortcut.
func relabelAllTabs() {
for w in windows {
w.controller.relabelTabs()
}
}
// MARK: - Notifications
@objc private func onNewWindow(notification: SwiftUI.Notification) {
let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey]
let config = configAny as? Ghostty.SurfaceConfiguration
self.newWindow(withBaseConfig: config)
}
@objc private func onNewTab(notification: SwiftUI.Notification) {
guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return }
guard let window = surfaceView.window else { return }
let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey]
let config = configAny as? Ghostty.SurfaceConfiguration
self.newTab(to: window, withBaseConfig: config)
}
}

View File

@ -5,15 +5,15 @@ class TerminalRestorableState: Codable {
static let selfKey = "state"
static let versionKey = "version"
static let version: Int = 2
let focusedSurface: String?
let surfaceTree: Ghostty.SplitNode?
init(from controller: TerminalController) {
self.focusedSurface = controller.focusedSurface?.uuid.uuidString
self.surfaceTree = controller.surfaceTree
}
init?(coder aDecoder: NSCoder) {
// If the version doesn't match then we can't decode. In the future we can perform
// version upgrading or something but for now we only have one version so we
@ -21,15 +21,15 @@ class TerminalRestorableState: Codable {
guard aDecoder.decodeInteger(forKey: Self.versionKey) == Self.version else {
return nil
}
guard let v = aDecoder.decodeObject(of: CodableBridge<Self>.self, forKey: Self.selfKey) else {
return nil
}
self.surfaceTree = v.value.surfaceTree
self.focusedSurface = v.value.focusedSurface
}
func encode(with coder: NSCoder) {
coder.encode(Self.version, forKey: Self.versionKey)
coder.encode(CodableBridge(self), forKey: Self.selfKey)
@ -56,27 +56,27 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration {
completionHandler(nil, TerminalRestoreError.identifierUnknown)
return
}
// The app delegate is definitely setup by now. If it isn't our AppDelegate
// then something is royally fucked up but protect against it anyhow.
guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else {
completionHandler(nil, TerminalRestoreError.delegateInvalid)
return
}
// If our configuration is "never" then we never restore the state
// no matter what.
if (appDelegate.terminalManager.ghostty.config.windowSaveState == "never") {
completionHandler(nil, nil)
return
}
// Decode the state. If we can't decode the state, then we can't restore.
guard let state = TerminalRestorableState(coder: state) else {
completionHandler(nil, TerminalRestoreError.stateDecodeFailed)
return
}
// The window creation has to go through our terminalManager so that it
// can be found for events from libghostty. This uses the low-level
// createWindow so that AppKit can place the window wherever it should
@ -86,7 +86,7 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration {
completionHandler(nil, TerminalRestoreError.windowDidNotLoad)
return
}
// Setup our restored state on the controller
if let focusedStr = state.focusedSurface,
let focusedUUID = UUID(uuidString: focusedStr),
@ -94,10 +94,10 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration {
c.focusedSurface = view
restoreFocus(to: view, inWindow: window)
}
completionHandler(window, nil)
}
/// This restores the focus state of the surfaceview within the given window. When restoring,
/// the view isn't immediately attached to the window since we have to wait for SwiftUI to
/// catch up. Therefore, we sit in an async loop waiting for the attachment to happen.
@ -113,19 +113,19 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration {
} else {
after = .now() + .milliseconds(50)
}
DispatchQueue.main.asyncAfter(deadline: after) {
// If the view is not attached to a window yet then we repeat.
guard let viewWindow = to.window else {
restoreFocus(to: to, inWindow: inWindow, attempts: attempts + 1)
return
}
// If the view is attached to some other window, we give up
guard viewWindow == inWindow else { return }
inWindow.makeFirstResponder(to)
// If the window is main, then we also make sure it comes forward. This
// prevents a bug found in #1177 where sometimes on restore the windows
// would be behind other applications.

View File

@ -4,12 +4,12 @@ import Cocoa
// in order to accommodate the titlebar tabs feature.
class TerminalToolbar: NSToolbar, NSToolbarDelegate {
private let titleTextField = CenteredDynamicLabel(labelWithString: "👻 Ghostty")
var titleText: String {
get {
titleTextField.stringValue
}
set {
titleTextField.stringValue = newValue
}
@ -27,16 +27,16 @@ class TerminalToolbar: NSToolbar, NSToolbarDelegate {
override init(identifier: NSToolbar.Identifier) {
super.init(identifier: identifier)
delegate = self
if #available(macOS 13.0, *) {
centeredItemIdentifiers.insert(.titleText)
} else {
centeredItemIdentifier = .titleText
}
}
func toolbar(_ toolbar: NSToolbar,
itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier,
willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? {
@ -68,11 +68,11 @@ class TerminalToolbar: NSToolbar, NSToolbarDelegate {
return item
}
func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
return [.titleText, .flexibleSpace, .space, .resetZoom]
}
func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
// These space items are here to ensure that the title remains centered when it starts
// getting smaller than the max size so starts clipping. Lucky for us, two of the
@ -88,11 +88,11 @@ fileprivate class CenteredDynamicLabel: NSTextField {
// Truncate the title when it gets too long, cutting it off with an ellipsis.
cell?.truncatesLastVisibleLine = true
cell?.lineBreakMode = .byCharWrapping
// Make the text field as small as possible while fitting its text.
setContentHuggingPriority(.required, for: .horizontal)
cell?.alignment = .center
// We've changed some alignment settings, make sure the layout is updated immediately.
needsLayout = true
}

View File

@ -7,13 +7,13 @@ import GhosttyKit
protocol TerminalViewDelegate: AnyObject {
/// Called when the currently focused surface changed. This can be nil.
func focusedSurfaceDidChange(to: Ghostty.SurfaceView?)
/// The title of the terminal should change.
func titleDidChange(to: String)
/// The cell size changed.
func cellSizeDidChange(to: NSSize)
/// The surface tree did change in some way, i.e. a split was added, removed, etc. This is
/// not called initially.
func surfaceTreeDidChange()
@ -41,35 +41,35 @@ protocol TerminalViewModel: ObservableObject {
/// The main terminal view. This terminal view supports splits.
struct TerminalView<ViewModel: TerminalViewModel>: View {
@ObservedObject var ghostty: Ghostty.App
// The required view model
@ObservedObject var viewModel: ViewModel
// An optional delegate to receive information about terminal changes.
weak var delegate: (any TerminalViewDelegate)? = nil
// This seems like a crutch after switching from SwiftUI to AppKit lifecycle.
@FocusState private var focused: Bool
// Various state values sent back up from the currently focused terminals.
@FocusedValue(\.ghosttySurfaceView) private var focusedSurface
@FocusedValue(\.ghosttySurfaceTitle) private var surfaceTitle
@FocusedValue(\.ghosttySurfaceZoomed) private var zoomedSplit
@FocusedValue(\.ghosttySurfaceCellSize) private var cellSize
// The title for our window
private var title: String {
var title = "👻"
if let surfaceTitle = surfaceTitle {
if (surfaceTitle.count > 0) {
title = surfaceTitle
}
}
return title
}
var body: some View {
switch ghostty.readiness {
case .loading:
@ -83,7 +83,7 @@ struct TerminalView<ViewModel: TerminalViewModel>: View {
if (Ghostty.info.mode == GHOSTTY_BUILD_MODE_DEBUG) {
DebugBuildWarningView()
}
Ghostty.TerminalSplit(node: $viewModel.surfaceTree)
.environmentObject(ghostty)
.focused($focused)
@ -114,14 +114,14 @@ struct TerminalView<ViewModel: TerminalViewModel>: View {
struct DebugBuildWarningView: View {
@State private var isPopover = false
var body: some View {
HStack {
Spacer()
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.yellow)
Text("You're running a debug build of Ghostty! Performance will be degraded.")
.padding(.all, 8)
.popover(isPresented: $isPopover, arrowEdge: .bottom) {
@ -132,7 +132,7 @@ struct DebugBuildWarningView: View {
""")
.padding(.all)
}
Spacer()
}
.background(Color(.windowBackgroundColor))

View File

@ -75,7 +75,7 @@ class TerminalWindow: NSWindow {
tab.attributedTitle = attributedTitle
}
}
// The window theme configuration from Ghostty. This is used to control some
// behaviors that don't look quite right in certain situations.
var windowTheme: TerminalWindowTheme?
@ -92,7 +92,7 @@ class TerminalWindow: NSWindow {
if let tabGroup = self.tabGroup, tabGroup.windows.count < 2 {
hideCustomTabBarViews()
}
super.becomeKey()
updateNewTabButtonOpacity()
@ -168,10 +168,10 @@ class TerminalWindow: NSWindow {
hideTitleBarSeparators()
}
}
override func mergeAllWindows(_ sender: Any?) {
super.mergeAllWindows(sender)
if let controller = self.windowController as? TerminalController {
// It takes an event loop cycle to merge all the windows so we set a
// short timer to relabel the tabs (issue #1902)
@ -185,15 +185,15 @@ class TerminalWindow: NSWindow {
var hasStyledTabs: Bool {
// If we have titlebar tabs then we always style.
guard !titlebarTabs else { return true }
// We style the tabs if they're transparent
return transparentTabs
}
// Set to true if the background color should bleed through the titlebar/tab bar.
// This only applies to non-titlebar tabs.
var transparentTabs: Bool = false
var hasVeryDarkBackground: Bool {
backgroundColor.luminance < 0.05
}
@ -406,7 +406,7 @@ class TerminalWindow: NSWindow {
// MARK: - Titlebar Tabs
private var windowButtonsBackdrop: WindowButtonsBackdropView? = nil
private var windowDragHandle: WindowDragView? = nil
// The tab bar controller ID from macOS
@ -459,27 +459,27 @@ class TerminalWindow: NSWindow {
childViewController.layoutAttribute == .bottom ||
childViewController.identifier == Self.TabBarController
)
if (isTabBar) {
// Ensure it has the right layoutAttribute to force it next to our titlebar
childViewController.layoutAttribute = .right
// If we don't set titleVisibility to hidden here, the toolbar will display a
// "collapsed items" indicator which interferes with the tab bar.
titleVisibility = .hidden
// Mark the controller for future reference so we can easily find it. Otherwise
// the tab bar has no ID by default.
childViewController.identifier = Self.TabBarController
}
super.addTitlebarAccessoryViewController(childViewController)
if (isTabBar) {
pushTabsToTitlebar(childViewController)
}
}
override func removeTitlebarAccessoryViewController(at index: Int) {
let isTabBar = titlebarAccessoryViewControllers[index].identifier == Self.TabBarController
super.removeTitlebarAccessoryViewController(at: index)
@ -487,16 +487,16 @@ class TerminalWindow: NSWindow {
hideCustomTabBarViews()
}
}
// To be called immediately after the tab bar is disabled.
private func hideCustomTabBarViews() {
// Hide the window buttons backdrop.
windowButtonsBackdrop?.isHidden = true
// Hide the window drag handle.
windowDragHandle?.isHidden = true
}
private func pushTabsToTitlebar(_ tabBarController: NSTitlebarAccessoryViewController) {
let accessoryView = tabBarController.view
guard let accessoryClipView = accessoryView.superview else { return }
@ -508,23 +508,23 @@ class TerminalWindow: NSWindow {
addWindowButtonsBackdrop(titlebarView: titlebarView, toolbarView: toolbarView)
guard let windowButtonsBackdrop = windowButtonsBackdrop else { return }
addWindowDragHandle(titlebarView: titlebarView, toolbarView: toolbarView)
accessoryClipView.translatesAutoresizingMaskIntoConstraints = false
accessoryClipView.leftAnchor.constraint(equalTo: windowButtonsBackdrop.rightAnchor).isActive = true
accessoryClipView.rightAnchor.constraint(equalTo: toolbarView.rightAnchor).isActive = true
accessoryClipView.topAnchor.constraint(equalTo: toolbarView.topAnchor).isActive = true
accessoryClipView.heightAnchor.constraint(equalTo: toolbarView.heightAnchor).isActive = true
accessoryClipView.needsLayout = true
accessoryView.translatesAutoresizingMaskIntoConstraints = false
accessoryView.leftAnchor.constraint(equalTo: accessoryClipView.leftAnchor).isActive = true
accessoryView.rightAnchor.constraint(equalTo: accessoryClipView.rightAnchor).isActive = true
accessoryView.topAnchor.constraint(equalTo: accessoryClipView.topAnchor).isActive = true
accessoryView.heightAnchor.constraint(equalTo: accessoryClipView.heightAnchor).isActive = true
accessoryView.needsLayout = true
// This is a horrible hack. During the transition while things are resizing to make room for
// new tabs or expand existing tabs to fill the empty space after one is closed, the centering
// of the tab titles can't be properly calculated, so we wait for 0.2 seconds and then mark
@ -541,7 +541,7 @@ class TerminalWindow: NSWindow {
let view = WindowButtonsBackdropView(window: self)
view.identifier = NSUserInterfaceItemIdentifier("_windowButtonsBackdrop")
titlebarView.addSubview(view)
view.translatesAutoresizingMaskIntoConstraints = false
view.leftAnchor.constraint(equalTo: toolbarView.leftAnchor).isActive = true
view.rightAnchor.constraint(equalTo: toolbarView.leftAnchor, constant: 78).isActive = true
@ -550,7 +550,7 @@ class TerminalWindow: NSWindow {
windowButtonsBackdrop = view
}
private func addWindowDragHandle(titlebarView: NSView, toolbarView: NSView) {
// If we already made the view, just make sure it's unhidden and correctly placed as a subview.
if let view = windowDragHandle {
@ -563,7 +563,7 @@ class TerminalWindow: NSWindow {
view.bottomAnchor.constraint(equalTo: toolbarView.topAnchor, constant: 12).isActive = true
return
}
let view = WindowDragView()
view.identifier = NSUserInterfaceItemIdentifier("_windowDragHandle")
titlebarView.superview?.addSubview(view)
@ -572,10 +572,10 @@ class TerminalWindow: NSWindow {
view.rightAnchor.constraint(equalTo: toolbarView.rightAnchor).isActive = true
view.topAnchor.constraint(equalTo: toolbarView.topAnchor).isActive = true
view.bottomAnchor.constraint(equalTo: toolbarView.topAnchor, constant: 12).isActive = true
windowDragHandle = view
}
// This forces this view and all subviews to update layout and redraw. This is
// a hack (see the caller).
private func markHierarchyForLayout(_ view: NSView) {
@ -600,19 +600,19 @@ fileprivate class WindowDragView: NSView {
super.mouseDown(with: event)
}
}
override public func mouseEntered(with event: NSEvent) {
super.mouseEntered(with: event)
window?.disableCursorRects()
NSCursor.openHand.set()
}
override func mouseExited(with event: NSEvent) {
super.mouseExited(with: event)
window?.enableCursorRects()
NSCursor.arrow.set()
}
override func resetCursorRects() {
addCursorRect(bounds, cursor: .openHand)
}

View File

@ -9,7 +9,7 @@ class UpdaterDelegate: NSObject, SPUUpdaterDelegate {
// tip appcast URL since it is all we support.
return "https://tip.files.ghostty.dev/appcast.xml"
}
func updaterWillRelaunchApplication(_ updater: SPUUpdater) {
// When the updater is relaunching the application we want to get macOS
// to invalidate and re-encode all of our restorable state so that when

View File

@ -5,7 +5,7 @@ import GhosttyKit
protocol GhosttyAppDelegate: AnyObject {
/// Called when the configuration did finish reloading.
func configDidReload(_ app: Ghostty.App)
#if os(macOS)
/// Called when a callback needs access to a specific surface. This should return nil
/// when the surface is no longer valid.
@ -20,18 +20,18 @@ extension Ghostty {
enum Readiness: String {
case loading, error, ready
}
/// Optional delegate
weak var delegate: GhosttyAppDelegate?
/// The readiness value of the state.
@Published var readiness: Readiness = .loading
/// The global app configuration. This defines the app level configuration plus any behavior
/// for new windows, tabs, etc. Note that when creating a new window, it may inherit some
/// configuration (i.e. font size) from the previously focused window. This would override this.
@Published private(set) var config: Config
/// The ghostty app instance. We only have one of these for the entire app, although I guess
/// in theory you can have multiple... I don't know why you would...
@Published var app: ghostty_app_t? = nil {
@ -40,13 +40,13 @@ extension Ghostty {
ghostty_app_free(old)
}
}
/// True if we need to confirm before quitting.
var needsConfirmQuit: Bool {
guard let app = app else { return false }
return ghostty_app_needs_confirm_quit(app)
}
init() {
// Initialize ghostty global state. This happens once per process.
if ghostty_init() != GHOSTTY_SUCCESS {
@ -60,7 +60,7 @@ extension Ghostty {
readiness = .error
return
}
// Create our "runtime" config. The "runtime" is the configuration that ghostty
// uses to interface with the application runtime environment.
var runtime_cfg = ghostty_runtime_config_s(
@ -96,7 +96,7 @@ extension Ghostty {
update_renderer_health_cb: { userdata, health in App.updateRendererHealth(userdata, health: health) },
mouse_over_link_cb: { userdata, ptr, len in App.mouseOverLink(userdata, uri: ptr, len: len) }
)
// Create the ghostty app.
guard let app = ghostty_app_new(&runtime_cfg, config.config) else {
logger.critical("ghostty_app_new failed")
@ -104,7 +104,7 @@ extension Ghostty {
return
}
self.app = app
#if os(macOS)
// Subscribe to notifications for keyboard layout change so that we can update Ghostty.
NotificationCenter.default.addObserver(
@ -113,14 +113,14 @@ extension Ghostty {
name: NSTextInputContext.keyboardSelectionDidChangeNotification,
object: nil)
#endif
self.readiness = .ready
}
deinit {
// This will force the didSet callbacks to run which free.
self.app = nil
#if os(macOS)
// Remove our observer
NotificationCenter.default.removeObserver(
@ -129,16 +129,16 @@ extension Ghostty {
object: nil)
#endif
}
// MARK: App Operations
func appTick() {
guard let app = self.app else { return }
// Tick our app, which lets us know if we want to quit
let exit = ghostty_app_tick(app)
if (!exit) { return }
// On iOS, applications do not terminate programmatically like they do
// on macOS. On iOS, applications are only terminated when a user physically
// closes the application (i.e. going to the home screen). If we request
@ -152,7 +152,7 @@ extension Ghostty {
NSApplication.shared.terminate(nil)
#endif
}
func openConfig() {
guard let app = self.app else { return }
ghostty_app_open_config(app)
@ -162,7 +162,7 @@ extension Ghostty {
guard let app = self.app else { return }
ghostty_app_reload_config(app)
}
/// Request that the given surface is closed. This will trigger the full normal surface close event
/// cycle which will call our close surface callback.
func requestClose(surface: ghostty_surface_t) {
@ -205,14 +205,14 @@ extension Ghostty {
logger.warning("action failed action=\(action)")
}
}
func toggleFullscreen(surface: ghostty_surface_t) {
let action = "toggle_fullscreen"
if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) {
logger.warning("action failed action=\(action)")
}
}
enum FontSizeModification {
case increase(Int)
case decrease(Int)
@ -233,24 +233,24 @@ extension Ghostty {
logger.warning("action failed action=\(action)")
}
}
func toggleTerminalInspector(surface: ghostty_surface_t) {
let action = "inspector:toggle"
if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) {
logger.warning("action failed action=\(action)")
}
}
func resetTerminal(surface: ghostty_surface_t) {
let action = "reset"
if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) {
logger.warning("action failed action=\(action)")
}
}
#if os(iOS)
// MARK: Ghostty Callbacks (iOS)
static func wakeup(_ userdata: UnsafeMutableRawPointer?) {}
static func reloadConfig(_ userdata: UnsafeMutableRawPointer?) -> ghostty_config_t? { return nil }
static func openConfig(_ userdata: UnsafeMutableRawPointer?) {}
@ -262,27 +262,27 @@ extension Ghostty {
location: ghostty_clipboard_e,
state: UnsafeMutableRawPointer?
) {}
static func confirmReadClipboard(
_ userdata: UnsafeMutableRawPointer?,
string: UnsafePointer<CChar>?,
state: UnsafeMutableRawPointer?,
request: ghostty_clipboard_request_e
) {}
static func writeClipboard(
_ userdata: UnsafeMutableRawPointer?,
string: UnsafePointer<CChar>?,
location: ghostty_clipboard_e,
confirm: Bool
) {}
static func newSplit(
_ userdata: UnsafeMutableRawPointer?,
direction: ghostty_split_direction_e,
direction: ghostty_split_direction_e,
config: ghostty_surface_config_s
) {}
static func newTab(_ userdata: UnsafeMutableRawPointer?, config: ghostty_surface_config_s) {}
static func newWindow(_ userdata: UnsafeMutableRawPointer?, config: ghostty_surface_config_s) {}
static func controlInspector(_ userdata: UnsafeMutableRawPointer?, mode: ghostty_inspector_mode_e) {}
@ -300,18 +300,18 @@ extension Ghostty {
static func updateRendererHealth(_ userdata: UnsafeMutableRawPointer?, health: ghostty_renderer_health_e) {}
static func mouseOverLink(_ userdata: UnsafeMutableRawPointer?, uri: UnsafePointer<CChar>?, len: Int) {}
#endif
#if os(macOS)
// MARK: Notifications
// Called when the selected keyboard changes. We have to notify Ghostty so that
// it can reload the keyboard mapping for input.
@objc private func keyboardSelectionDidChange(notification: NSNotification) {
guard let app = self.app else { return }
ghostty_app_keyboard_changed(app)
}
// MARK: Ghostty Callbacks (macOS)
static func newSplit(_ userdata: UnsafeMutableRawPointer?, direction: ghostty_split_direction_e, config: ghostty_surface_config_s) {
@ -384,17 +384,17 @@ extension Ghostty {
// to leak "state".
let surfaceView = self.surfaceUserdata(from: userdata)
guard let surface = surfaceView.surface else { return }
// We only support the standard clipboard
if (location != GHOSTTY_CLIPBOARD_STANDARD) {
return completeClipboardRequest(surface, data: "", state: state)
}
// Get our string
let str = NSPasteboard.general.getOpinionatedStringContents() ?? ""
completeClipboardRequest(surface, data: str, state: state)
}
static func confirmReadClipboard(
_ userdata: UnsafeMutableRawPointer?,
string: UnsafePointer<CChar>?,
@ -414,7 +414,7 @@ extension Ghostty {
]
)
}
static func completeClipboardRequest(
_ surface: ghostty_surface_t,
data: String,
@ -439,7 +439,7 @@ extension Ghostty {
pb.setString(valueStr, forType: .string)
return
}
NotificationCenter.default.post(
name: Notification.confirmClipboard,
object: surface,
@ -483,7 +483,7 @@ extension Ghostty {
// standpoint since we don't do this much.
DispatchQueue.main.async { state.appTick() }
}
static func renderInspector(_ userdata: UnsafeMutableRawPointer?) {
let surface = self.surfaceUserdata(from: userdata)
NotificationCenter.default.post(
@ -520,7 +520,7 @@ extension Ghostty {
]
)
}
static func setInitialWindowSize(_ userdata: UnsafeMutableRawPointer?, width: UInt32, height: UInt32) {
// We need a window to set the frame
let surfaceView = self.surfaceUserdata(from: userdata)
@ -532,14 +532,14 @@ extension Ghostty {
let backingSize = NSSize(width: Double(width), height: Double(height))
surfaceView.cellSize = surfaceView.convertFromBacking(backingSize)
}
static func mouseOverLink(_ userdata: UnsafeMutableRawPointer?, uri: UnsafePointer<CChar>?, len: Int) {
let surfaceView = self.surfaceUserdata(from: userdata)
guard len > 0 else {
surfaceView.hoverUrl = nil
return
}
let buffer = Data(bytes: uri!, count: len)
surfaceView.hoverUrl = String(data: buffer, encoding: .utf8)
}
@ -593,7 +593,7 @@ extension Ghostty {
static func newTab(_ userdata: UnsafeMutableRawPointer?, config: ghostty_surface_config_s) {
let surface = self.surfaceUserdata(from: userdata)
guard let appState = self.appState(fromView: surface) else { return }
guard appState.config.windowDecorations else {
let alert = NSAlert()
@ -604,7 +604,7 @@ extension Ghostty {
_ = alert.runModal()
return
}
NotificationCenter.default.post(
name: Notification.ghosttyNewTab,
object: surface,
@ -625,18 +625,18 @@ extension Ghostty {
]
)
}
static func controlInspector(_ userdata: UnsafeMutableRawPointer?, mode: ghostty_inspector_mode_e) {
let surface = self.surfaceUserdata(from: userdata)
NotificationCenter.default.post(name: Notification.didControlInspector, object: surface, userInfo: [
"mode": mode,
])
}
static func updateRendererHealth(_ userdata: UnsafeMutableRawPointer?, health: ghostty_renderer_health_e) {
let surface = self.surfaceUserdata(from: userdata)
NotificationCenter.default.post(
name: Notification.didUpdateRendererHealth,
name: Notification.didUpdateRendererHealth,
object: surface,
userInfo: [
"health": health,
@ -656,7 +656,7 @@ extension Ghostty {
static private func surfaceUserdata(from userdata: UnsafeMutableRawPointer?) -> SurfaceView {
return Unmanaged<SurfaceView>.fromOpaque(userdata!).takeUnretainedValue()
}
#endif
}
}

View File

@ -14,14 +14,14 @@ extension Ghostty {
ghostty_config_free(old)
}
}
/// True if the configuration is loaded
var loaded: Bool { config != nil }
/// Return the errors found while loading the configuration.
var errors: [String] {
guard let cfg = self.config else { return [] }
var errors: [String] = [];
let errCount = ghostty_config_errors_count(cfg)
for i in 0..<errCount {
@ -29,20 +29,20 @@ extension Ghostty {
let message = String(cString: err.message)
errors.append(message)
}
return errors
}
init() {
if let cfg = Self.loadConfig() {
self.config = cfg
}
}
deinit {
self.config = nil
}
/// Initializes a new configuration and loads all the values.
static private func loadConfig() -> ghostty_config_t? {
// Initialize the global configuration.
@ -50,7 +50,7 @@ extension Ghostty {
logger.critical("ghostty_config_new failed")
return nil
}
// Load our configuration from files, CLI args, and then any referenced files.
// We only do this on macOS because other Apple platforms do not have the
// same filesystem concept.
@ -59,14 +59,14 @@ extension Ghostty {
ghostty_config_load_cli_args(cfg);
ghostty_config_load_recursive_files(cfg);
#endif
// TODO: we'd probably do some config loading here... for now we'd
// have to do this synchronously. When we support config updating we can do
// this async and update later.
// Finalize will make our defaults available.
ghostty_config_finalize(cfg)
// Log any configuration errors. These will be automatically shown in a
// pop-up window too.
let errCount = ghostty_config_errors_count(cfg)
@ -80,32 +80,32 @@ extension Ghostty {
logger.warning("config error: \(message)")
}
}
return cfg
}
#if os(macOS)
// MARK: - Keybindings
/// A convenience struct that has the key + modifiers for some keybinding.
struct KeyEquivalent: CustomStringConvertible {
let key: String
let modifiers: NSEvent.ModifierFlags
var description: String {
var key = self.key
// Note: the order below matters; it matches the ordering modifiers
// shown for macOS menu shortcut labels.
if modifiers.contains(.command) { key = "\(key)" }
if modifiers.contains(.shift) { key = "\(key)" }
if modifiers.contains(.option) { key = "\(key)" }
if modifiers.contains(.control) { key = "\(key)" }
return key
}
}
/// Return the key equivalent for the given action. The action is the name of the action
/// in the Ghostty configuration. For example `keybind = cmd+q=quit` in Ghostty
/// configuration would be "quit" action.
@ -113,7 +113,7 @@ extension Ghostty {
/// Returns nil if there is no key equivalent for the given action.
func keyEquivalent(for action: String) -> KeyEquivalent? {
guard let cfg = self.config else { return nil }
let trigger = ghostty_config_trigger(cfg, action, UInt(action.count))
let equiv: String
switch (trigger.tag) {
@ -123,34 +123,34 @@ extension Ghostty {
} else {
return nil
}
case GHOSTTY_TRIGGER_PHYSICAL:
if let v = Ghostty.keyEquivalent(key: trigger.key.physical) {
equiv = v
} else {
return nil
}
case GHOSTTY_TRIGGER_UNICODE:
equiv = String(trigger.key.unicode)
default:
return nil
}
return KeyEquivalent(
key: equiv,
modifiers: Ghostty.eventModifierFlags(mods: trigger.mods)
)
}
#endif
// MARK: - Configuration Values
/// For all of the configuration values below, see the associated Ghostty documentation for
/// details on what each means. We only add documentation if there is a strange conversion
/// due to the embedded library and Swift.
var shouldQuitAfterLastWindowClosed: Bool {
guard let config = self.config else { return true }
var v = false;
@ -158,7 +158,7 @@ extension Ghostty {
_ = ghostty_config_get(config, &v, key, UInt(key.count))
return v
}
var windowColorspace: String {
guard let config = self.config else { return "" }
var v: UnsafePointer<Int8>? = nil
@ -167,7 +167,7 @@ extension Ghostty {
guard let ptr = v else { return "" }
return String(cString: ptr)
}
var windowSaveState: String {
guard let config = self.config else { return "" }
var v: UnsafePointer<Int8>? = nil
@ -176,7 +176,7 @@ extension Ghostty {
guard let ptr = v else { return "" }
return String(cString: ptr)
}
var windowNewTabPosition: String {
guard let config = self.config else { return "" }
var v: UnsafePointer<Int8>? = nil
@ -185,7 +185,7 @@ extension Ghostty {
guard let ptr = v else { return "" }
return String(cString: ptr)
}
var windowDecorations: Bool {
guard let config = self.config else { return true }
var v = false;
@ -193,7 +193,7 @@ extension Ghostty {
_ = ghostty_config_get(config, &v, key, UInt(key.count))
return v;
}
var windowTheme: String? {
guard let config = self.config else { return nil }
var v: UnsafePointer<Int8>? = nil
@ -202,7 +202,7 @@ extension Ghostty {
guard let ptr = v else { return nil }
return String(cString: ptr)
}
var windowStepResize: Bool {
guard let config = self.config else { return true }
var v = false
@ -210,7 +210,7 @@ extension Ghostty {
_ = ghostty_config_get(config, &v, key, UInt(key.count))
return v
}
var windowFullscreen: Bool {
guard let config = self.config else { return true }
var v = false
@ -237,7 +237,7 @@ extension Ghostty {
guard let ptr = v else { return defaultValue }
return String(cString: ptr)
}
var macosWindowShadow: Bool {
guard let config = self.config else { return false }
var v = false;
@ -266,18 +266,18 @@ extension Ghostty {
#error("unsupported")
#endif
}
let red = Double(rgb & 0xff)
let green = Double((rgb >> 8) & 0xff)
let blue = Double((rgb >> 16) & 0xff)
return Color(
red: red / 255,
green: green / 255,
blue: blue / 255
)
}
var backgroundOpacity: Double {
guard let config = self.config else { return 1 }
var v: Double = 1
@ -285,7 +285,7 @@ extension Ghostty {
_ = ghostty_config_get(config, &v, key, UInt(key.count))
return v;
}
var backgroundBlurRadius: Int {
guard let config = self.config else { return 1 }
var v: Int = 0
@ -293,7 +293,7 @@ extension Ghostty {
_ = ghostty_config_get(config, &v, key, UInt(key.count))
return v;
}
var unfocusedSplitOpacity: Double {
guard let config = self.config else { return 1 }
var opacity: Double = 0.85
@ -301,28 +301,28 @@ extension Ghostty {
_ = ghostty_config_get(config, &opacity, key, UInt(key.count))
return 1 - opacity
}
var unfocusedSplitFill: Color {
guard let config = self.config else { return .white }
var rgb: UInt32 = 16777215 // white default
let key = "unfocused-split-fill"
if (!ghostty_config_get(config, &rgb, key, UInt(key.count))) {
let bg_key = "background"
_ = ghostty_config_get(config, &rgb, bg_key, UInt(bg_key.count));
}
let red = Double(rgb & 0xff)
let green = Double((rgb >> 8) & 0xff)
let blue = Double((rgb >> 16) & 0xff)
return Color(
red: red / 255,
green: green / 255,
blue: blue / 255
)
}
// This isn't actually a configurable value currently but it could be done day.
// We put it here because it is a color that changes depending on the configuration.
var splitDividerColor: Color {
@ -331,7 +331,7 @@ extension Ghostty {
let newColor = isLightBackground ? backgroundColor.darken(by: 0.08) : backgroundColor.darken(by: 0.4)
return Color(newColor)
}
var resizeOverlay: ResizeOverlay {
guard let config = self.config else { return .after_first }
var v: UnsafePointer<Int8>? = nil
@ -341,7 +341,7 @@ extension Ghostty {
let str = String(cString: ptr)
return ResizeOverlay(rawValue: str) ?? .after_first
}
var resizeOverlayPosition: ResizeOverlayPosition {
let defaultValue = ResizeOverlayPosition.center
guard let config = self.config else { return defaultValue }
@ -352,7 +352,7 @@ extension Ghostty {
let str = String(cString: ptr)
return ResizeOverlayPosition(rawValue: str) ?? defaultValue
}
var resizeOverlayDuration: UInt {
guard let config = self.config else { return 1000 }
var v: UInt = 0
@ -371,7 +371,7 @@ extension Ghostty.Config {
case never
case after_first = "after-first"
}
enum ResizeOverlayPosition : String {
case center
case top_left = "top-left"
@ -380,28 +380,28 @@ extension Ghostty.Config {
case bottom_left = "bottom-left"
case bottom_center = "bottom-center"
case bottom_right = "bottom-right"
func top() -> Bool {
switch (self) {
case .top_left, .top_center, .top_right: return true;
default: return false;
}
}
func bottom() -> Bool {
switch (self) {
case .bottom_left, .bottom_center, .bottom_right: return true;
default: return false;
}
}
func left() -> Bool {
switch (self) {
case .top_left, .bottom_left: return true;
default: return false;
}
}
func right() -> Bool {
switch (self) {
case .top_right, .bottom_right: return true;

View File

@ -16,7 +16,7 @@ extension Ghostty {
if (mods.rawValue & GHOSTTY_MODS_SUPER.rawValue != 0) { flags.insert(.command) }
return flags
}
/// Translate event modifier flags to a ghostty mods enum.
static func ghosttyMods(_ flags: NSEvent.ModifierFlags) -> ghostty_input_mods_e {
var mods: UInt32 = GHOSTTY_MODS_NONE.rawValue
@ -37,7 +37,7 @@ extension Ghostty {
return ghostty_input_mods_e(mods)
}
/// A map from the Ghostty key enum to the keyEquivalent string for shortcuts.
static let keyToEquivalent: [ghostty_input_key_e : String] = [
// 0-9
@ -220,7 +220,7 @@ extension Ghostty {
0x3B: GHOSTTY_KEY_SEMICOLON,
0x2F: GHOSTTY_KEY_SLASH,
]
// Mapping of event keyCode to ghostty input key values. This is cribbed from
// glfw mostly since we started as a glfw-based app way back in the day!
static let keycodeToKey: [UInt16 : ghostty_input_key_e] = [
@ -338,4 +338,3 @@ extension Ghostty {
0x4E: GHOSTTY_KEY_KP_SUBTRACT,
];
}

View File

@ -2,7 +2,7 @@ extension Ghostty {
struct Shell {
// Characters to escape in the shell.
static let escapeCharacters = "\\ ()[]{}<>\"'`!#$&;|*?\t"
/// Escape shell-sensitive characters in string.
static func escape(_ str: String) -> String {
var result = str
@ -12,7 +12,7 @@ extension Ghostty {
with: "\\\(char)"
)
}
return result
}
}

View File

@ -14,7 +14,7 @@ extension Ghostty {
enum SplitNode: Equatable, Hashable, Codable, Sequence {
case leaf(Leaf)
case split(Container)
/// The parent of this node.
var parent: Container? {
get {
@ -26,7 +26,7 @@ extension Ghostty {
return container.parent
}
}
set {
switch (self) {
case .leaf(let leaf):
@ -37,7 +37,7 @@ extension Ghostty {
}
}
}
/// Returns the view that would prefer receiving focus in this tree. This is always the
/// top-left-most view. This is used when creating a split or closing a split to find the
/// next view to send focus to.
@ -51,16 +51,16 @@ extension Ghostty {
case .split(let c):
container = c
}
let node: SplitNode
switch (direction) {
case .previous, .top, .left:
node = container.bottomRight
case .next, .bottom, .right:
node = container.topLeft
}
return node.preferredFocus(direction)
}
@ -95,7 +95,7 @@ extension Ghostty {
container.bottomRight.close()
}
}
/// Returns true if any surface in the split stack requires quit confirmation.
func needsConfirmQuit() -> Bool {
switch (self) {
@ -119,7 +119,7 @@ extension Ghostty {
container.bottomRight.contains(view: view)
}
}
/// Find a surface view by UUID.
func findUUID(uuid: UUID) -> SurfaceView? {
switch (self) {
@ -127,7 +127,7 @@ extension Ghostty {
if (leaf.surface.uuid == uuid) {
return leaf.surface
}
return nil
case .split(let container):
@ -135,13 +135,13 @@ extension Ghostty {
container.bottomRight.findUUID(uuid: uuid)
}
}
// MARK: - Sequence
func makeIterator() -> IndexingIterator<[Leaf]> {
return leaves().makeIterator()
}
/// Return all the leaves in this split node. This isn't very efficient but our split trees are never super
/// deep so its not an issue.
private func leaves() -> [Leaf] {
@ -153,9 +153,9 @@ extension Ghostty {
return container.topLeft.leaves() + container.bottomRight.leaves()
}
}
// MARK: - Equatable
static func == (lhs: SplitNode, rhs: SplitNode) -> Bool {
switch (lhs, rhs) {
case (.leaf(let lhs_v), .leaf(let rhs_v)):
@ -178,27 +178,27 @@ extension Ghostty {
self.app = app
self.surface = SurfaceView(app, baseConfig: baseConfig, uuid: uuid)
}
// MARK: - Hashable
func hash(into hasher: inout Hasher) {
hasher.combine(app)
hasher.combine(surface)
}
// MARK: - Equatable
static func == (lhs: Leaf, rhs: Leaf) -> Bool {
return lhs.app == rhs.app && lhs.surface === rhs.surface
}
// MARK: - Codable
enum CodingKeys: String, CodingKey {
case pwd
case uuid
}
required convenience init(from decoder: Decoder) throws {
// Decoding uses the global Ghostty app
guard let del = NSApplication.shared.delegate,
@ -206,15 +206,15 @@ extension Ghostty {
let app = appDel.ghostty.app else {
throw TerminalRestoreError.delegateInvalid
}
let container = try decoder.container(keyedBy: CodingKeys.self)
let uuid = UUID(uuidString: try container.decode(String.self, forKey: .uuid))
var config = SurfaceConfiguration()
config.workingDirectory = try container.decode(String?.self, forKey: .pwd)
self.init(app, baseConfig: config, uuid: uuid)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(surface.pwd, forKey: .pwd)
@ -333,32 +333,32 @@ extension Ghostty {
}
// MARK: - Hashable
func hash(into hasher: inout Hasher) {
hasher.combine(app)
hasher.combine(direction)
hasher.combine(topLeft)
hasher.combine(bottomRight)
}
// MARK: - Equatable
static func == (lhs: Container, rhs: Container) -> Bool {
return lhs.app == rhs.app &&
lhs.direction == rhs.direction &&
lhs.topLeft == rhs.topLeft &&
lhs.bottomRight == rhs.bottomRight
}
// MARK: - Codable
enum CodingKeys: String, CodingKey {
case direction
case split
case topLeft
case bottomRight
}
required init(from decoder: Decoder) throws {
// Decoding uses the global Ghostty app
guard let del = NSApplication.shared.delegate,
@ -366,19 +366,19 @@ extension Ghostty {
let app = appDel.ghostty.app else {
throw TerminalRestoreError.delegateInvalid
}
let container = try decoder.container(keyedBy: CodingKeys.self)
self.app = app
self.direction = try container.decode(SplitViewDirection.self, forKey: .direction)
self.split = try container.decode(CGFloat.self, forKey: .split)
self.topLeft = try container.decode(SplitNode.self, forKey: .topLeft)
self.bottomRight = try container.decode(SplitNode.self, forKey: .bottomRight)
// Fix up the parent references
self.topLeft.parent = self
self.bottomRight.parent = self
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(direction, forKey: .direction)
@ -429,7 +429,7 @@ extension Ghostty {
}
return clone
}
/// True if there are no neighbors
func isEmpty() -> Bool {
return self.previous == nil && self.next == nil

View File

@ -61,7 +61,7 @@ extension Ghostty {
switch (node) {
case nil:
Color(.clear)
case .leaf(let leaf):
TerminalSplitLeaf(
leaf: leaf,
@ -94,7 +94,7 @@ extension Ghostty {
.onReceive(pubFocus) { onZoomReset(notification: $0) }
}
}
func onZoom(notification: SwiftUI.Notification) {
// Our node must be split to receive zooms. You can't zoom an unsplit terminal.
if case .leaf = node {
@ -182,14 +182,14 @@ extension Ghostty {
node = nil
return
}
// If we don't have a window to attach our modal to, we also exit immediately.
// This should NOT happen.
guard let window = leaf.surface.window else {
node = nil
return
}
// Confirm close. We use an NSAlert instead of a SwiftUI confirmationDialog
// due to SwiftUI bugs (see Ghostty #560). To repeat from #560, the bug is that
// confirmationDialog allows the user to Cmd-W close the alert, but when doing
@ -206,7 +206,7 @@ extension Ghostty {
switch (response) {
case .alertFirstButtonReturn:
node = nil
default:
break
}
@ -277,7 +277,7 @@ extension Ghostty {
parent.resize(direction: direction, amount: amount)
}
}
/// This represents a split view that is in the horizontal or vertical split state.
private struct TerminalSplitContainer: View {
@EnvironmentObject var ghostty: Ghostty.App
@ -315,7 +315,7 @@ extension Ghostty {
)
})
}
private func closeableTopLeft() -> Binding<SplitNode?> {
return .init(get: {
container.topLeft
@ -324,7 +324,7 @@ extension Ghostty {
container.topLeft = newValue
return
}
// Closing
container.topLeft.close()
node = container.bottomRight
@ -346,7 +346,7 @@ extension Ghostty {
}
})
}
private func closeableBottomRight() -> Binding<SplitNode?> {
return .init(get: {
container.bottomRight
@ -355,7 +355,7 @@ extension Ghostty {
container.bottomRight = newValue
return
}
// Closing
container.bottomRight.close()
node = container.topLeft
@ -379,7 +379,7 @@ extension Ghostty {
}
}
/// This is like TerminalSplitRoot, but... not the root. This renders a SplitNode in any state but
/// requires there be a binding to the parent node.
private struct TerminalSplitNested: View {
@ -410,7 +410,7 @@ extension Ghostty {
.id(node)
}
}
/// 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

View File

@ -11,7 +11,7 @@ extension Ghostty {
/// Same as SurfaceWrapper, see the doc comments there.
@ObservedObject var surfaceView: SurfaceView
var isSplit: Bool = false
// Maintain whether our view has focus or not
@FocusState private var inspectorFocus: Bool
@ -21,7 +21,7 @@ extension Ghostty {
var body: some View {
let center = NotificationCenter.default
let pubInspector = center.publisher(for: Notification.didControlInspector, object: surfaceView)
ZStack {
if (!surfaceView.inspectorVisible) {
SurfaceWrapper(surfaceView: surfaceView, isSplit: isSplit)
@ -51,28 +51,28 @@ extension Ghostty {
}
}
}
private func onControlInspector(_ notification: SwiftUI.Notification) {
// Determine our mode
guard let modeAny = notification.userInfo?["mode"] else { return }
guard let mode = modeAny as? ghostty_inspector_mode_e else { return }
switch (mode) {
case GHOSTTY_INSPECTOR_TOGGLE:
surfaceView.inspectorVisible = !surfaceView.inspectorVisible
case GHOSTTY_INSPECTOR_SHOW:
surfaceView.inspectorVisible = true
case GHOSTTY_INSPECTOR_HIDE:
surfaceView.inspectorVisible = false
default:
return
}
}
}
struct InspectorViewRepresentable: NSViewRepresentable {
/// The surface that this inspector represents.
let surfaceView: SurfaceView
@ -87,25 +87,25 @@ extension Ghostty {
view.surfaceView = self.surfaceView
}
}
/// Inspector view is the view for the surface inspector (similar to a web inspector).
class InspectorView: MTKView, NSTextInputClient {
let commandQueue: MTLCommandQueue
var surfaceView: SurfaceView? = nil {
didSet { surfaceViewDidChange() }
}
private var inspector: ghostty_inspector_t? {
guard let surfaceView = self.surfaceView else { return nil }
return surfaceView.inspector
}
private var markedText: NSMutableAttributedString = NSMutableAttributedString()
// We need to support being a first responder so that we can get input events
override var acceptsFirstResponder: Bool { return true }
override init(frame: CGRect, device: MTLDevice?) {
// Initialize our Metal primitives
guard
@ -113,44 +113,44 @@ extension Ghostty {
let commandQueue = device.makeCommandQueue() else {
fatalError("GPU not available")
}
// Setup our properties before initializing the parent
self.commandQueue = commandQueue
super.init(frame: frame, device: device)
// This makes it so renders only happen when we request
self.enableSetNeedsDisplay = true
self.isPaused = true
// After initializing the parent we can set our own properties
self.device = MTLCreateSystemDefaultDevice()
self.clearColor = MTLClearColor(red: 0x28 / 0xFF, green: 0x2C / 0xFF, blue: 0x34 / 0xFF, alpha: 1.0)
// Setup our tracking areas for mouse events
updateTrackingAreas()
}
required init(coder: NSCoder) {
fatalError("init(coder:) is not supported for this view")
}
deinit {
trackingAreas.forEach { removeTrackingArea($0) }
NotificationCenter.default.removeObserver(self)
}
// MARK: Internal Inspector Funcs
private func surfaceViewDidChange() {
let center = NotificationCenter.default
center.removeObserver(self)
guard let surfaceView = self.surfaceView else { return }
guard let inspector = self.inspector else { return }
guard let device = self.device else { return }
let devicePtr = Unmanaged.passRetained(device).toOpaque()
ghostty_inspector_metal_init(inspector, devicePtr)
// Register an observer for render requests
center.addObserver(
self,
@ -158,11 +158,11 @@ extension Ghostty {
name: Ghostty.Notification.inspectorNeedsDisplay,
object: surfaceView)
}
@objc private func didRequestRender(notification: SwiftUI.Notification) {
self.needsDisplay = true
}
private func updateSize() {
guard let inspector = self.inspector else { return }
@ -175,9 +175,9 @@ extension Ghostty {
// When our scale factor changes, so does our fb size so we send that too
ghostty_inspector_set_size(inspector, UInt32(fbFrame.size.width), UInt32(fbFrame.size.height))
}
// MARK: NSView
override func becomeFirstResponder() -> Bool {
let result = super.becomeFirstResponder()
if (result) {
@ -197,7 +197,7 @@ extension Ghostty {
}
return result
}
override func updateTrackingAreas() {
// To update our tracking area we just recreate it all.
trackingAreas.forEach { removeTrackingArea($0) }
@ -207,7 +207,7 @@ extension Ghostty {
rect: frame,
options: [
.mouseMoved,
// Only send mouse events that happen in our visible (not obscured) rect
.inVisibleRect,
@ -218,12 +218,12 @@ extension Ghostty {
owner: self,
userInfo: nil))
}
override func viewDidChangeBackingProperties() {
super.viewDidChangeBackingProperties()
updateSize()
}
override func mouseDown(with event: NSEvent) {
guard let inspector = self.inspector else { return }
let mods = Ghostty.ghosttyMods(event.modifierFlags)
@ -247,10 +247,10 @@ extension Ghostty {
let mods = Ghostty.ghosttyMods(event.modifierFlags)
ghostty_inspector_mouse_button(inspector, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_RIGHT, mods)
}
override func mouseMoved(with event: NSEvent) {
guard let inspector = self.inspector else { return }
// Convert window position to view position. Note (0, 0) is bottom left.
let pos = self.convert(event.locationInWindow, from: nil)
ghostty_inspector_mouse_pos(inspector, pos.x, frame.height - pos.y)
@ -260,7 +260,7 @@ extension Ghostty {
override func mouseDragged(with event: NSEvent) {
self.mouseMoved(with: event)
}
override func scrollWheel(with event: NSEvent) {
guard let inspector = self.inspector else { return }
@ -303,7 +303,7 @@ extension Ghostty {
ghostty_inspector_mouse_scroll(inspector, x, y, mods)
}
override func keyDown(with event: NSEvent) {
let action = event.isARepeat ? GHOSTTY_ACTION_REPEAT : GHOSTTY_ACTION_PRESS
keyAction(action, event: event)
@ -342,7 +342,7 @@ extension Ghostty {
let mods = Ghostty.ghosttyMods(event.modifierFlags)
ghostty_inspector_key(inspector, action, key, mods)
}
// MARK: NSTextInputClient
func hasMarkedText() -> Bool {
@ -406,10 +406,10 @@ extension Ghostty {
default:
return
}
let len = chars.utf8CString.count
if (len == 0) { return }
chars.withCString { ptr in
ghostty_inspector_text(inspector, ptr)
}
@ -419,25 +419,25 @@ extension Ghostty {
// This currently just prevents NSBeep from interpretKeyEvents but in the future
// we may want to make some of this work.
}
// MARK: MTKView
override func draw(_ dirtyRect: NSRect) {
guard
let commandBuffer = self.commandQueue.makeCommandBuffer(),
let descriptor = self.currentRenderPassDescriptor else {
return
}
// If the inspector is nil, then our surface is freed and it is unsafe
// to use.
guard let inspector = self.inspector else { return }
// We always update our size because sometimes draw is called
// between resize events and if our size is wrong with the underlying
// drawable we will crash.
updateSize()
// Render
ghostty_inspector_metal_render(
inspector,

View File

@ -8,7 +8,7 @@ struct Ghostty {
subsystem: Bundle.main.bundleIdentifier!,
category: "ghostty"
)
// All the notifications that will be emitted will be put here.
struct Notification {}
@ -26,7 +26,7 @@ extension Ghostty {
var mode: ghostty_build_mode_e
var version: String
}
static var info: Info {
let raw = ghostty_info()
let version = NSString(
@ -45,50 +45,50 @@ extension Ghostty {
/// An enum that is used for the directions that a split focus event can change.
enum SplitFocusDirection {
case previous, next, top, bottom, left, right
/// Initialize from a Ghostty API enum.
static func from(direction: ghostty_split_focus_direction_e) -> Self? {
switch (direction) {
case GHOSTTY_SPLIT_FOCUS_PREVIOUS:
return .previous
case GHOSTTY_SPLIT_FOCUS_NEXT:
return .next
case GHOSTTY_SPLIT_FOCUS_TOP:
return .top
case GHOSTTY_SPLIT_FOCUS_BOTTOM:
return .bottom
case GHOSTTY_SPLIT_FOCUS_LEFT:
return .left
case GHOSTTY_SPLIT_FOCUS_RIGHT:
return .right
default:
return nil
}
}
func toNative() -> ghostty_split_focus_direction_e {
switch (self) {
case .previous:
return GHOSTTY_SPLIT_FOCUS_PREVIOUS
case .next:
return GHOSTTY_SPLIT_FOCUS_NEXT
case .top:
return GHOSTTY_SPLIT_FOCUS_TOP
case .bottom:
return GHOSTTY_SPLIT_FOCUS_BOTTOM
case .left:
return GHOSTTY_SPLIT_FOCUS_LEFT
case .right:
return GHOSTTY_SPLIT_FOCUS_RIGHT
}
@ -177,49 +177,49 @@ extension Ghostty {
extension Ghostty.Notification {
/// Used to pass a configuration along when creating a new tab/window/split.
static let NewSurfaceConfigKey = "com.mitchellh.ghostty.newSurfaceConfig"
/// Posted when a new split is requested. The sending object will be the surface that had focus. The
/// userdata has one key "direction" with the direction to split to.
static let ghosttyNewSplit = Notification.Name("com.mitchellh.ghostty.newSplit")
/// Close the calling surface.
static let ghosttyCloseSurface = Notification.Name("com.mitchellh.ghostty.closeSurface")
/// Focus previous/next split. Has a SplitFocusDirection in the userinfo.
static let ghosttyFocusSplit = Notification.Name("com.mitchellh.ghostty.focusSplit")
static let SplitDirectionKey = ghosttyFocusSplit.rawValue
/// Goto tab. Has tab index in the userinfo.
static let ghosttyGotoTab = Notification.Name("com.mitchellh.ghostty.gotoTab")
static let GotoTabKey = ghosttyGotoTab.rawValue
/// New tab. Has base surface config requested in userinfo.
static let ghosttyNewTab = Notification.Name("com.mitchellh.ghostty.newTab")
/// New window. Has base surface config requested in userinfo.
static let ghosttyNewWindow = Notification.Name("com.mitchellh.ghostty.newWindow")
/// Toggle fullscreen of current window
static let ghosttyToggleFullscreen = Notification.Name("com.mitchellh.ghostty.toggleFullscreen")
static let NonNativeFullscreenKey = ghosttyToggleFullscreen.rawValue
/// Notification that a surface is becoming focused. This is only sent on macOS 12 to
/// work around bugs. macOS 13+ should use the ".focused()" attribute.
static let didBecomeFocusedSurface = Notification.Name("com.mitchellh.ghostty.didBecomeFocusedSurface")
/// Notification sent to toggle split maximize/unmaximize.
static let didToggleSplitZoom = Notification.Name("com.mitchellh.ghostty.didToggleSplitZoom")
/// Notification
static let didReceiveInitialWindowFrame = Notification.Name("com.mitchellh.ghostty.didReceiveInitialWindowFrame")
static let FrameKey = "com.mitchellh.ghostty.frame"
/// Notification to render the inspector for a surface
static let inspectorNeedsDisplay = Notification.Name("com.mitchellh.ghostty.inspectorNeedsDisplay")
/// Notification to show/hide the inspector
static let didControlInspector = Notification.Name("com.mitchellh.ghostty.didControlInspector")
static let confirmClipboard = Notification.Name("com.mitchellh.ghostty.confirmClipboard")
static let ConfirmClipboardStrKey = confirmClipboard.rawValue + ".str"
static let ConfirmClipboardStateKey = confirmClipboard.rawValue + ".state"
@ -232,7 +232,7 @@ extension Ghostty.Notification {
/// Notification sent to the split root to equalize split sizes
static let didEqualizeSplits = Notification.Name("com.mitchellh.ghostty.didEqualizeSplits")
/// Notification that renderer health changed
static let didUpdateRendererHealth = Notification.Name("com.mitchellh.ghostty.didUpdateRendererHealth")
}

View File

@ -33,7 +33,7 @@ extension Ghostty {
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.
@ -42,21 +42,21 @@ extension Ghostty {
// 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
@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
@ -65,7 +65,7 @@ extension Ghostty {
// We use these notifications to determine when the window our surface is
// attached to is or is not focused.
let pubBecomeFocused = center.publisher(for: Notification.didBecomeFocusedSurface, object: surfaceView)
#if canImport(AppKit)
let pubBecomeKey = center.publisher(for: NSWindow.didBecomeKeyNotification)
let pubResign = center.publisher(for: NSWindow.didResignKeyNotification)
@ -102,7 +102,7 @@ extension Ghostty {
}
}
}
return true
}
#endif
@ -145,8 +145,8 @@ extension Ghostty {
// I don't know how older macOS versions behave but Ghostty only
// supports back to macOS 12 so its moot.
}
// If our geo size changed then we show the resize overlay as configured.
// If our geo size changed then we show the resize overlay as configured.
if let surfaceSize = surfaceView.surfaceSize {
SurfaceResizeOverlay(
geoSize: geo.size,
@ -155,11 +155,11 @@ extension Ghostty {
position: ghostty.config.resizeOverlayPosition,
duration: ghostty.config.resizeOverlayDuration,
focusInstant: surfaceView.focusInstant)
}
}
.ghosttySurfaceView(surfaceView)
// If we have a URL from hovering a link, we show that.
if let url = surfaceView.hoverUrl {
let padding: CGFloat = 3
@ -168,7 +168,7 @@ extension Ghostty {
Spacer()
VStack(alignment: .leading) {
Spacer()
Text(verbatim: url)
.padding(.init(top: padding, leading: padding, bottom: padding, trailing: padding))
.background(.background)
@ -177,11 +177,11 @@ extension Ghostty {
.opacity(isHoveringURLLeft ? 1 : 0)
}
}
HStack {
VStack(alignment: .leading) {
Spacer()
Text(verbatim: url)
.padding(.init(top: padding, leading: padding, bottom: padding, trailing: padding))
.background(.background)
@ -196,7 +196,7 @@ extension Ghostty {
}
}
}
// If our surface is not healthy, then we render an error view over it.
if (!surfaceView.healthy) {
Rectangle().fill(ghostty.config.backgroundColor)
@ -222,7 +222,7 @@ extension Ghostty {
}
}
}
struct SurfaceRendererUnhealthyView: View {
var body: some View {
HStack {
@ -230,7 +230,7 @@ extension Ghostty {
.resizable()
.scaledToFit()
.frame(width: 128, height: 128)
VStack(alignment: .leading) {
Text("Oh, no. 😭").font(.title)
Text("""
@ -244,7 +244,7 @@ extension Ghostty {
.padding()
}
}
struct SurfaceErrorView: View {
var body: some View {
HStack {
@ -252,7 +252,7 @@ extension Ghostty {
.resizable()
.scaledToFit()
.frame(width: 128, height: 128)
VStack(alignment: .leading) {
Text("Oh, no. 😭").font(.title)
Text("""
@ -266,7 +266,7 @@ extension Ghostty {
.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 {
@ -276,26 +276,26 @@ extension Ghostty {
let position: Ghostty.Config.ResizeOverlayPosition
let duration: UInt
let focusInstant: Any?
// 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 #available(macOS 13, iOS 16, *) {
@ -308,7 +308,7 @@ extension Ghostty {
}
}
}
// Hidden depending on overlay config
switch (overlay) {
case .never: return true;
@ -316,18 +316,18 @@ extension Ghostty {
case .after_first: return lastSize == nil;
}
}
var body: some View {
VStack {
if (!position.top()) {
Spacer()
}
HStack {
if (!position.left()) {
Spacer()
}
Text(verbatim: "\(size.columns)c \(size.rows)r")
.padding(.init(top: padding, leading: padding, bottom: padding, trailing: padding))
.background(
@ -337,12 +337,12 @@ extension Ghostty {
)
.lineLimit(1)
.truncationMode(.tail)
if (!position.right()) {
Spacer()
}
}
if (!position.bottom()) {
Spacer()
}
@ -360,18 +360,18 @@ extension Ghostty {
// 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
}
}
}
/// 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.
@ -404,27 +404,27 @@ extension Ghostty {
view.sizeDidChange(size)
}
}
/// 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
init() {}
init(from config: ghostty_surface_config_s) {
self.fontSize = config.font_size
self.workingDirectory = String.init(cString: config.working_directory, encoding: .utf8)
self.command = String.init(cString: config.command, encoding: .utf8)
}
/// Returns the ghostty configuration for this surface configuration struct. The memory
/// in the returned struct is only valid as long as this struct is retained.
func ghosttyConfig(view: SurfaceView) -> ghostty_surface_config_s {
@ -436,7 +436,7 @@ extension Ghostty {
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(
@ -450,7 +450,7 @@ extension Ghostty {
#else
#error("unsupported target")
#endif
if let fontSize = fontSize { config.font_size = fontSize }
if let workingDirectory = workingDirectory {
config.working_directory = (workingDirectory as NSString).utf8String
@ -458,7 +458,7 @@ extension Ghostty {
if let command = command {
config.command = (command as NSString).utf8String
}
return config
}
}

View File

@ -8,7 +8,7 @@ extension Ghostty {
class SurfaceView: OSView, ObservableObject {
/// Unique ID per surface
let uuid: UUID
// The current title of the surface as defined by the pty. This can be
// changed with escape codes. This is public because the callbacks go
// to the app level and it is set from there.
@ -19,57 +19,57 @@ extension Ghostty {
// when the font size changes). This is used to allow windows to be
// resized in discrete steps of a single cell.
@Published var cellSize: NSSize = .zero
// The health state of the surface. This currently only reflects the
// renderer health. In the future we may want to make this an enum.
@Published var healthy: Bool = true
// Any error while initializing the surface.
@Published var error: Error? = nil
// The hovered URL string
@Published var hoverUrl: String? = nil
// The time this surface last became focused. This is a ContinuousClock.Instant
// on supported platforms.
@Published var focusInstant: Any? = nil
// An initial size to request for a window. This will only affect
// then the view is moved to a new window.
var initialSize: NSSize? = nil
// Returns true if quit confirmation is required for this surface to
// exit safely.
var needsConfirmQuit: Bool {
guard let surface = self.surface else { return false }
return ghostty_surface_needs_confirm_quit(surface)
}
/// Returns the pwd of the surface if it has one.
var pwd: String? {
guard let surface = self.surface else { return nil }
let v = String(unsafeUninitializedCapacity: 1024) {
Int(ghostty_surface_pwd(surface, $0.baseAddress, UInt($0.count)))
}
if (v.count == 0) { return nil }
return v
}
// Returns sizing information for the surface. This is the raw C
// structure because I'm lazy.
var surfaceSize: ghostty_surface_size_s? {
guard let surface = self.surface else { return nil }
return ghostty_surface_size(surface)
}
// Returns the inspector instance for this surface, or nil if the
// surface has been closed.
var inspector: ghostty_inspector_t? {
guard let surface = self.surface else { return nil }
return ghostty_surface_inspector(surface)
}
// True if the inspector should be visible
@Published var inspectorVisible: Bool = false {
didSet {
@ -82,7 +82,7 @@ extension Ghostty {
// Notification identifiers associated with this surface
var notificationIdentifiers: Set<String> = []
private(set) var surface: ghostty_surface_t?
private var markedText: NSMutableAttributedString
private var mouseEntered: Bool = false
@ -91,10 +91,10 @@ extension Ghostty {
private var cursor: NSCursor = .iBeam
private var cursorVisible: CursorVisibility = .visible
private var appearanceObserver: NSKeyValueObservation? = nil
// This is set to non-null during keyDown to accumulate insertText contents
private var keyTextAccumulator: [String]? = nil
// We need to support being a first responder so that we can get input events
override var acceptsFirstResponder: Bool { return true }
@ -119,7 +119,7 @@ extension Ghostty {
// is non-zero so that our layer bounds are non-zero so that our renderer
// can do SOMETHING.
super.init(frame: NSMakeRect(0, 0, 800, 600))
// Before we initialize the surface we want to register our notifications
// so there is no window where we can't receive them.
let center = NotificationCenter.default
@ -133,7 +133,7 @@ extension Ghostty {
selector: #selector(windowDidChangeScreen),
name: NSWindow.didChangeScreenNotification,
object: nil)
// Setup our surface. This will also initialize all the terminal IO.
let surface_cfg = baseConfig ?? SurfaceConfiguration()
var surface_cfg_c = surface_cfg.ghosttyConfig(view: self)
@ -142,10 +142,10 @@ extension Ghostty {
return
}
self.surface = surface;
// Setup our tracking area so we get mouse moved events
updateTrackingAreas()
// Observe our appearance so we can report the correct value to libghostty.
// This is the best way I know of to get appearance change notifications.
self.appearanceObserver = observe(\.effectiveAppearance, options: [.new, .initial]) { view, change in
@ -155,14 +155,14 @@ extension Ghostty {
switch (appearance.name) {
case .aqua, .vibrantLight:
scheme = GHOSTTY_COLOR_SCHEME_LIGHT
case .darkAqua, .vibrantDark:
scheme = GHOSTTY_COLOR_SCHEME_DARK
default:
return
}
ghostty_surface_set_color_scheme(surface, scheme)
}
}
@ -175,20 +175,20 @@ extension Ghostty {
// Remove all of our notificationcenter subscriptions
let center = NotificationCenter.default
center.removeObserver(self)
// Whenever the surface is removed, we need to note that our restorable
// state is invalid to prevent the surface from being restored.
invalidateRestorableState()
trackingAreas.forEach { removeTrackingArea($0) }
// mouseExited is not called by AppKit one last time when the view
// closes so we do it manually to ensure our NSCursor state remains
// accurate.
if (mouseEntered) {
mouseExited(with: NSEvent())
}
guard let surface = self.surface else { return }
ghostty_surface_free(surface)
}
@ -212,7 +212,7 @@ extension Ghostty {
guard self.focused != focused else { return }
self.focused = focused
ghostty_surface_set_focus(surface, focused)
// On macOS 13+ we can store our continuous clock...
if #available(macOS 13, iOS 16, *) {
if (focused) {
@ -230,7 +230,7 @@ extension Ghostty {
// The size represents our final size we're going for.
let scaledSize = self.convertToBacking(size)
ghostty_surface_set_size(surface, UInt32(scaledSize.width), UInt32(scaledSize.height))
// Frame changes do not always call mouseEntered/mouseExited, so we do some
// calculations ourself to call those events.
if let window = self.window {
@ -309,7 +309,7 @@ extension Ghostty {
window.invalidateCursorRects(for: self)
}
}
func setCursorVisibility(_ visible: Bool) {
switch (cursorVisible) {
case .visible:
@ -317,19 +317,19 @@ extension Ghostty {
// enter the pending state.
if (visible) { return }
cursorVisible = .pendingHidden
case .hidden:
// If we want to be hidden, do nothing. If we want to be visible
// enter the pending state.
if (!visible) { return }
cursorVisible = .pendingVisible
case .pendingVisible:
// If we want to be visible, do nothing because we're already pending.
// If we want to be hidden, we're already hidden so reset state.
if (visible) { return }
cursorVisible = .hidden
case .pendingHidden:
// If we want to be hidden, do nothing because we're pending that switch.
// If we want to be visible, we're already visible so reset state.
@ -341,30 +341,30 @@ extension Ghostty {
cursorUpdate(with: NSEvent())
}
}
// MARK: - Notifications
@objc private func onUpdateRendererHealth(notification: SwiftUI.Notification) {
guard let healthAny = notification.userInfo?["health"] else { return }
guard let health = healthAny as? ghostty_renderer_health_e else { return }
healthy = health == GHOSTTY_RENDERER_HEALTH_OK
}
@objc private func windowDidChangeScreen(notification: SwiftUI.Notification) {
guard let window = self.window else { return }
guard let object = notification.object as? NSWindow, window == object else { return }
guard let screen = window.screen else { return }
guard let surface = self.surface else { return }
// When the window changes screens, we need to update libghostty with the screen
// ID. If vsync is enabled, this will be used with the CVDisplayLink to ensure
// the proper refresh rate is going.
let id = (screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as! NSNumber).uint32Value
ghostty_surface_set_display_id(surface, id)
}
// MARK: - NSView
override func becomeFirstResponder() -> Bool {
let result = super.becomeFirstResponder()
if (result) { focusDidChange(true) }
@ -391,7 +391,7 @@ extension Ghostty {
options: [
.mouseEnteredAndExited,
.mouseMoved,
// Only send mouse events that happen in our visible (not obscured) rect
.inVisibleRect,
@ -410,7 +410,7 @@ extension Ghostty {
override func viewDidChangeBackingProperties() {
super.viewDidChangeBackingProperties()
// The Core Animation compositing engine uses the layer's contentsScale property
// to determine whether to scale its contents during compositing. When the window
// moves between a high DPI display and a low DPI display, or the user modifies
@ -431,7 +431,7 @@ extension Ghostty {
layer?.contentsScale = window.backingScaleFactor
CATransaction.commit()
}
guard let surface = self.surface else { return }
// Detect our X/Y scale factor so we can update our surface
@ -448,7 +448,7 @@ extension Ghostty {
guard let surface = self.surface else { return }
ghostty_surface_draw(surface);
}
override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
// "Override this method in a subclass to allow instances to respond to
// click-through. This allows the user to click on a view in an inactive
@ -466,12 +466,12 @@ extension Ghostty {
override func mouseUp(with event: NSEvent) {
// Always reset our pressure when the mouse goes up
prevPressureStage = 0
// If we have an active surface, report the event
guard let surface = self.surface else { return }
let mods = Ghostty.ghosttyMods(event.modifierFlags)
ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, mods)
// Release pressure
ghostty_surface_mouse_pressure(surface, 0, 0)
}
@ -493,7 +493,7 @@ extension Ghostty {
override func rightMouseDown(with event: NSEvent) {
guard let surface = self.surface else { return super.rightMouseDown(with: event) }
let mods = Ghostty.ghosttyMods(event.modifierFlags)
if (ghostty_surface_mouse_button(
surface,
@ -504,14 +504,14 @@ extension Ghostty {
// Consumed
return
}
// Mouse event not consumed
super.rightMouseDown(with: event)
}
override func rightMouseUp(with event: NSEvent) {
guard let surface = self.surface else { return super.rightMouseUp(with: event) }
let mods = Ghostty.ghosttyMods(event.modifierFlags)
if (ghostty_surface_mouse_button(
surface,
@ -522,14 +522,14 @@ extension Ghostty {
// Handled
return
}
// Mouse event not consumed
super.rightMouseUp(with: event)
}
override func mouseMoved(with event: NSEvent) {
guard let surface = self.surface else { return }
// Convert window position to view position. Note (0, 0) is bottom left.
let pos = self.convert(event.locationInWindow, from: nil)
ghostty_surface_mouse_pos(surface, pos.x, frame.height - pos.y)
@ -554,9 +554,9 @@ extension Ghostty {
// tab is created. In this scenario, we only want to process our
// callback once since this is stateful and we expect balancing.
if (mouseEntered) { return }
mouseEntered = true
// Update our cursor when we enter so we fully process our
// cursorVisible state.
cursorUpdate(with: NSEvent())
@ -565,9 +565,9 @@ extension Ghostty {
override func mouseExited(with event: NSEvent) {
// See mouseEntered
if (!mouseEntered) { return }
mouseEntered = false
// If the mouse is currently hidden, we want to show it when we exit
// this view. We go through the cursorVisible dance so that only
// cursorUpdate manages cursor state.
@ -575,7 +575,7 @@ extension Ghostty {
cursorVisible = .pendingVisible
cursorUpdate(with: NSEvent())
assert(cursorVisible == .visible)
// We set the state to pending hidden again for the next time
// we enter.
cursorVisible = .pendingHidden
@ -624,42 +624,42 @@ extension Ghostty {
ghostty_surface_mouse_scroll(surface, x, y, mods)
}
override func pressureChange(with event: NSEvent) {
guard let surface = self.surface else { return }
// Notify Ghostty first. We do this because this will let Ghostty handle
// state setup that we'll need for later pressure handling (such as
// QuickLook)
ghostty_surface_mouse_pressure(surface, UInt32(event.stage), Double(event.pressure))
// Pressure stage 2 is force click. We only want to execute this on the
// initial transition to stage 2, and not for any repeated events.
guard self.prevPressureStage < 2 else { return }
prevPressureStage = event.stage
guard event.stage == 2 else { return }
// If the user has force click enabled then we do a quick look. There
// is no public API for this as far as I can tell.
guard UserDefaults.standard.bool(forKey: "com.apple.trackpad.forceClick") else { return }
quickLook(with: event)
}
override func cursorUpdate(with event: NSEvent) {
switch (cursorVisible) {
case .visible, .hidden:
// Do nothing, stable state
break
case .pendingHidden:
NSCursor.hide()
cursorVisible = .hidden
case .pendingVisible:
NSCursor.unhide()
cursorVisible = .visible
}
cursor.set()
}
@ -668,7 +668,7 @@ extension Ghostty {
self.interpretKeyEvents([event])
return
}
// We need to translate the mods (maybe) to handle configs such as option-as-alt
let translationModsGhostty = Ghostty.eventModifierFlags(
mods: ghostty_surface_key_translation_mods(
@ -676,7 +676,7 @@ extension Ghostty {
Ghostty.ghosttyMods(event.modifierFlags)
)
)
// There are hidden bits set in our event that matter for certain dead keys
// so we can't use translationModsGhostty directly. Instead, we just check
// for exact states and set them.
@ -711,21 +711,21 @@ extension Ghostty {
keyCode: event.keyCode
) ?? event
}
let action = event.isARepeat ? GHOSTTY_ACTION_REPEAT : GHOSTTY_ACTION_PRESS
// By setting this to non-nil, we note that we're in a keyDown event. From here,
// we call interpretKeyEvents so that we can handle complex input such as Korean
// language.
keyTextAccumulator = []
defer { keyTextAccumulator = nil }
// We need to know what the length of marked text was before this event to
// know if these events cleared it.
let markedTextBefore = markedText.length > 0
self.interpretKeyEvents([translationEvent])
// If we have text, then we've composed a character, send that down. We do this
// first because if we completed a preedit, the text will be available here
// AND we'll have a preedit.
@ -736,7 +736,7 @@ extension Ghostty {
keyAction(action, event: event, text: text)
}
}
// If we have marked text, we're in a preedit state. Send that down.
// If we don't have marked text but we had marked text before, then the preedit
// was cleared so we want to send down an empty string to ensure we've cleared
@ -745,7 +745,7 @@ extension Ghostty {
handled = true
keyAction(action, event: event, preedit: markedText.string)
}
if (!handled) {
// No text or anything, we want to handle this manually.
keyAction(action, event: event)
@ -768,7 +768,7 @@ extension Ghostty {
if (event.type != .keyDown) {
return false
}
// Only process events if we're focused. Some key events like C-/ macOS
// appears to send to the first view in the hierarchy rather than the
// the first responder (I don't know why). This prevents us from handling it.
@ -782,7 +782,7 @@ extension Ghostty {
// Treat C-/ as C-_. We do this because C-/ makes macOS make a beep
// sound and we don't like the beep sound.
equivalent = "_"
default:
// Ignore other events
return false
@ -840,18 +840,18 @@ extension Ghostty {
default:
sidePressed = true
}
if (sidePressed) {
action = GHOSTTY_ACTION_PRESS
}
}
keyAction(action, event: event)
}
private func keyAction(_ action: ghostty_input_action_e, event: NSEvent) {
guard let surface = self.surface else { return }
var key_ev = ghostty_input_key_s()
key_ev.action = action
key_ev.mods = Ghostty.ghosttyMods(event.modifierFlags)
@ -860,7 +860,7 @@ extension Ghostty {
key_ev.composing = false
ghostty_surface_key(surface, key_ev)
}
private func keyAction(_ action: ghostty_input_action_e, event: NSEvent, preedit: String) {
guard let surface = self.surface else { return }
@ -887,17 +887,17 @@ extension Ghostty {
ghostty_surface_key(surface, key_ev)
}
}
override func quickLook(with event: NSEvent) {
guard let surface = self.surface else { return super.quickLook(with: event) }
// Grab the text under the cursor
var info: ghostty_selection_s = ghostty_selection_s();
let text = String(unsafeUninitializedCapacity: 1000000) {
Int(ghostty_surface_quicklook_word(surface, $0.baseAddress, UInt($0.count), &info))
}
guard !text.isEmpty else { return super.quickLook(with: event) }
// If we can get a font then we use the font. This should always work
// since we always have a primary font. The only scenario this doesn't
// work is if someone is using a non-CoreText build which would be
@ -911,25 +911,25 @@ extension Ghostty {
attributes[.font] = font.takeUnretainedValue()
font.release()
}
// Ghostty coordinate system is top-left, convert to bottom-left for AppKit
let pt = NSMakePoint(info.tl_px_x - 2, frame.size.height - info.tl_px_y + 2)
let str = NSAttributedString.init(string: text, attributes: attributes)
self.showDefinition(for: str, at: pt);
}
override func menu(for event: NSEvent) -> NSMenu? {
// We only support right-click menus
switch event.type {
case .rightMouseDown:
// Good
break
case .leftMouseDown:
if !event.modifierFlags.contains(.control) {
return nil
}
// In this case, AppKit calls menu BEFORE calling any mouse events.
// If mouse capturing is enabled then we never show the context menu
// so that we can handle ctrl+left-click in the terminal app.
@ -937,7 +937,7 @@ extension Ghostty {
if ghostty_surface_mouse_captured(surface) {
return nil
}
// If we return a non-nil menu then mouse events will never be
// processed by the core, so we need to manually send a right
// mouse down event.
@ -951,13 +951,13 @@ extension Ghostty {
GHOSTTY_MOUSE_RIGHT,
mods
)
default:
return nil
}
let menu = NSMenu()
// If we have a selection, add copy
if self.selectedRange().length > 0 {
menu.addItem(withTitle: "Copy", action: #selector(copy(_:)), keyEquivalent: "")
@ -974,7 +974,7 @@ extension Ghostty {
return menu
}
// MARK: Menu Handlers
@IBAction func copy(_ sender: Any?) {
@ -992,7 +992,7 @@ extension Ghostty {
AppDelegate.logger.warning("action failed action=\(action)")
}
}
@IBAction func pasteAsPlainText(_ sender: Any?) {
guard let surface = self.surface else { return }
@ -1001,7 +1001,7 @@ extension Ghostty {
AppDelegate.logger.warning("action failed action=\(action)")
}
}
@IBAction override func selectAll(_ sender: Any?) {
guard let surface = self.surface else { return }
let action = "select_all"
@ -1063,7 +1063,7 @@ extension Ghostty.SurfaceView: NSTextInputClient {
func selectedRange() -> NSRange {
guard let surface = self.surface else { return NSRange() }
// Get our range from the Ghostty API. There is a race condition between getting the
// range and actually using it since our selection may change but there isn't a good
// way I can think of to solve this for AppKit.
@ -1097,21 +1097,21 @@ extension Ghostty.SurfaceView: NSTextInputClient {
// Ghostty.logger.warning("pressure substring range=\(range) selectedRange=\(self.selectedRange())")
guard let surface = self.surface else { return nil }
guard ghostty_surface_has_selection(surface) else { return nil }
// If the range is empty then we don't need to return anything
guard range.length > 0 else { return nil }
// I used to do a bunch of testing here that the range requested matches the
// selection range or contains it but a lot of macOS system behaviors request
// bogus ranges I truly don't understand so we just always return the
// attributed string containing our selection which is... weird but works?
// Get our selection. We cap it at 1MB for the purpose of this. This is
// arbitrary. If this is a good reason to increase it I'm happy to.
let v = String(unsafeUninitializedCapacity: 1000000) {
Int(ghostty_surface_selection(surface, $0.baseAddress, UInt($0.count)))
}
// If we can get a font then we use the font. This should always work
// since we always have a primary font. The only scenario this doesn't
// work is if someone is using a non-CoreText build which would be
@ -1137,11 +1137,11 @@ extension Ghostty.SurfaceView: NSTextInputClient {
guard let surface = self.surface else {
return NSMakeRect(frame.origin.x, frame.origin.y, 0, 0)
}
// Ghostty will tell us where it thinks an IME keyboard should render.
var x: Double = 0;
var y: Double = 0;
// QuickLook never gives us a matching range to our selection so if we detect
// this then we return the top-left selection point rather than the cursor point.
// This is hacky but I can't think of a better way to get the right IME vs. QuickLook
@ -1164,7 +1164,7 @@ extension Ghostty.SurfaceView: NSTextInputClient {
// Ghostty coordinates are in top-left (0, 0) so we have to convert to
// bottom-left since that is what UIKit expects
let viewRect = NSMakeRect(x, frame.size.height - y, 0, 0)
// Convert the point to the window coordinates
let winRect = self.convert(viewRect, to: nil)
@ -1188,10 +1188,10 @@ extension Ghostty.SurfaceView: NSTextInputClient {
default:
return
}
// If insertText is called, our preedit must be over.
unmarkText()
// If we have an accumulator we're in another key event so we just
// accumulate and return.
if var acc = keyTextAccumulator {
@ -1199,10 +1199,10 @@ extension Ghostty.SurfaceView: NSTextInputClient {
keyTextAccumulator = acc
return
}
let len = chars.utf8CString.count
if (len == 0) { return }
chars.withCString { ptr in
// len includes the null terminator so we do len - 1
ghostty_surface_text(surface, ptr, UInt(len - 1))
@ -1227,49 +1227,49 @@ extension Ghostty.SurfaceView: NSServicesMenuRequestor {
) -> Any? {
// Types that we accept sent to us
let accepted: [NSPasteboard.PasteboardType] = [.string, .init("public.utf8-plain-text")]
// We can always receive the accepted types
if (returnType == nil || accepted.contains(returnType!)) {
return self
}
// If we have a selection we can send the accepted types too
if ((self.surface != nil && ghostty_surface_has_selection(self.surface)) &&
(sendType == nil || accepted.contains(sendType!))
) {
return self
}
return super.validRequestor(forSendType: sendType, returnType: returnType)
}
func writeSelection(
to pboard: NSPasteboard,
types: [NSPasteboard.PasteboardType]
) -> Bool {
guard let surface = self.surface else { return false }
// We currently cap the maximum copy size to 1MB. iTerm2 I believe
// caps theirs at 0.1MB (configurable) so this is probably reasonable.
let v = String(unsafeUninitializedCapacity: 1000000) {
Int(ghostty_surface_selection(surface, $0.baseAddress, UInt($0.count)))
}
pboard.declareTypes([.string], owner: nil)
pboard.setString(v, forType: .string)
return true
}
func readSelection(from pboard: NSPasteboard) -> Bool {
guard let str = pboard.getOpinionatedStringContents() else { return false }
let len = str.utf8CString.count
if (len == 0) { return true }
str.withCString { ptr in
// len includes the null terminator so we do len - 1
ghostty_surface_text(surface, ptr, UInt(len - 1))
}
return true
}
}

View File

@ -6,7 +6,7 @@ extension Ghostty {
class SurfaceView: UIView, ObservableObject {
/// Unique ID per surface
let uuid: UUID
// The current title of the surface as defined by the pty. This can be
// changed with escape codes. This is public because the callbacks go
// to the app level and it is set from there.
@ -17,30 +17,30 @@ extension Ghostty {
// when the font size changes). This is used to allow windows to be
// resized in discrete steps of a single cell.
@Published var cellSize: OSSize = .zero
// The health state of the surface. This currently only reflects the
// renderer health. In the future we may want to make this an enum.
@Published var healthy: Bool = true
// Any error while initializing the surface.
@Published var error: Error? = nil
// The hovered URL
@Published var hoverUrl: String? = nil
// The time this surface last became focused. This is a ContinuousClock.Instant
// on supported platforms.
@Published var focusInstant: Any? = nil
// Returns sizing information for the surface. This is the raw C
// structure because I'm lazy.
var surfaceSize: ghostty_surface_size_s? {
guard let surface = self.surface else { return nil }
return ghostty_surface_size(surface)
}
private(set) var surface: ghostty_surface_t?
init(_ app: ghostty_app_t, baseConfig: SurfaceConfiguration? = nil, uuid: UUID? = nil) {
self.uuid = uuid ?? .init()
@ -58,7 +58,7 @@ extension Ghostty {
}
self.surface = surface;
}
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported for this view")
}
@ -67,11 +67,11 @@ extension Ghostty {
guard let surface = self.surface else { return }
ghostty_surface_free(surface)
}
func focusDidChange(_ focused: Bool) {
guard let surface = self.surface else { return }
ghostty_surface_set_focus(surface, focused)
// On macOS 13+ we can store our continuous clock...
if #available(macOS 13, iOS 16, *) {
if (focused) {
@ -95,15 +95,15 @@ extension Ghostty {
UInt32(size.height * scale)
)
}
// MARK: UIView
override class var layerClass: AnyClass {
get {
return CAMetalLayer.self
}
}
override func didMoveToWindow() {
sizeDidChange(frame.size)
}

View File

@ -4,16 +4,16 @@ import Cocoa
class CodableBridge<Wrapped: Codable>: NSObject, NSSecureCoding {
let value: Wrapped
init(_ value: Wrapped) { self.value = value }
static var supportsSecureCoding: Bool { return true }
required init?(coder aDecoder: NSCoder) {
guard let data = aDecoder.decodeObject(of: NSData.self, forKey: "data") as? Data else { return nil }
guard let archiver = try? NSKeyedUnarchiver(forReadingFrom: data) else { return nil }
guard let value = archiver.decodeDecodable(Wrapped.self, forKey: "value") else { return nil }
self.value = value
}
func encode(with aCoder: NSCoder) {
let archiver = NSKeyedArchiver(requiringSecureCoding: true)
try? archiver.encodeEncodable(value, forKey: "value")

View File

@ -1,18 +1,18 @@
import SwiftUI
import GhosttyKit
class FullScreenHandler {
class FullScreenHandler {
var previousTabGroup: NSWindowTabGroup?
var previousTabGroupIndex: Int?
var previousContentFrame: NSRect?
var previousStyleMask: NSWindow.StyleMask? = nil
// We keep track of whether we entered non-native fullscreen in case
// a user goes to fullscreen, changes the config to disable non-native fullscreen
// and then wants to toggle it off
var isInNonNativeFullscreen: Bool = false
var isInFullscreen: Bool = false
func toggleFullscreen(window: NSWindow, nonNativeFullscreen: ghostty_non_native_fullscreen_e) {
let useNonNativeFullscreen = nonNativeFullscreen != GHOSTTY_NON_NATIVE_FULLSCREEN_FALSE
if isInFullscreen {
@ -40,17 +40,17 @@ class FullScreenHandler {
isInFullscreen = true
}
}
func enterFullscreen(window: NSWindow, hideMenu: Bool) {
guard let screen = window.screen else { return }
guard let contentView = window.contentView else { return }
previousTabGroup = window.tabGroup
previousTabGroupIndex = window.tabGroup?.windows.firstIndex(of: window)
// Save previous contentViewFrame and screen
previousContentFrame = window.convertToScreen(contentView.frame)
// Change presentation style to hide menu bar and dock if needed
// It's important to do this in two calls, because setting them in a single call guarantees
// that the menu bar will also be hidden on any additional displays (why? nobody knows!)
@ -61,7 +61,7 @@ class FullScreenHandler {
// has not yet been hidden, so the order matters here!
if (shouldHideDock(screen: screen)) {
self.hideDock()
// Ensure that we always hide the dock bar for this window, but not for non fullscreen ones
NotificationCenter.default.addObserver(
self,
@ -76,7 +76,7 @@ class FullScreenHandler {
}
if (hideMenu) {
self.hideMenu()
// Ensure that we always hide the menu bar for this window, but not for non fullscreen ones
// This is not the best way to do this, not least because it causes the menu to stay visible
// for a brief moment before being hidden in some cases (e.g. when switching spaces).
@ -93,28 +93,28 @@ class FullScreenHandler {
name: NSWindow.didResignMainNotification,
object: window)
}
// This is important: it gives us the full screen, including the
// notch area on MacBooks.
self.previousStyleMask = window.styleMask
window.styleMask.remove(.titled)
// Set frame to screen size, accounting for the menu bar if needed
let frame = calculateFullscreenFrame(screen: screen, subtractMenu: !hideMenu)
window.setFrame(frame, display: true)
// Focus window
window.makeKeyAndOrderFront(nil)
}
@objc func hideMenu() {
NSApp.presentationOptions.insert(.autoHideMenuBar)
}
@objc func onDidResignMain(_ notification: Notification) {
guard let resigningWindow = notification.object as? NSWindow else { return }
guard let mainWindow = NSApplication.shared.mainWindow else { return }
// We're only unhiding the menu bar, if the focus shifted within our application.
// In that case, `mainWindow` is the window of our application the focus shifted
// to.
@ -122,20 +122,20 @@ class FullScreenHandler {
NSApp.presentationOptions.remove(.autoHideMenuBar)
}
}
@objc func hideDock() {
NSApp.presentationOptions.insert(.autoHideDock)
}
@objc func unHideDock() {
NSApp.presentationOptions.remove(.autoHideDock)
}
func calculateFullscreenFrame(screen: NSScreen, subtractMenu: Bool)->NSRect {
if (subtractMenu) {
if let menuHeight = NSApp.mainMenu?.menuBarHeight {
var padding: CGFloat = 0
// Detect the notch. If there is a safe area on top it includes the
// menu height as a safe area so we also subtract that from it.
if (screen.safeAreaInsets.top > 0) {
@ -152,34 +152,34 @@ class FullScreenHandler {
}
return screen.frame
}
func leaveFullscreen(window: NSWindow) {
guard let previousFrame = previousContentFrame else { return }
// Restore the style mask
window.styleMask = self.previousStyleMask!
// Restore previous presentation options
NSApp.presentationOptions = []
// Stop handling any window focus notifications
// that we use to manage menu bar visibility
NotificationCenter.default.removeObserver(self, name: NSWindow.didBecomeMainNotification, object: window)
NotificationCenter.default.removeObserver(self, name: NSWindow.didResignMainNotification, object: window)
// Restore frame
window.setFrame(window.frameRect(forContentRect: previousFrame), display: true)
// Have titlebar tabs set itself up again, since removing the titlebar when fullscreen breaks its constraints.
if let window = window as? TerminalWindow, window.titlebarTabs {
window.titlebarTabs = true
}
// If the window was previously in a tab group that isn't empty now, we re-add it
if let group = previousTabGroup, let tabIndex = previousTabGroupIndex, !group.windows.isEmpty {
var tabWindow: NSWindow?
var order: NSWindow.OrderingMode = .below
// Index of the window before `window`
let tabIndexBefore = tabIndex-1
if tabIndexBefore < 0 {
@ -194,15 +194,15 @@ class FullScreenHandler {
// If index is after group, add it after last window
tabWindow = group.windows.last
}
// Add the window
tabWindow?.addTabbedWindow(window, ordered: order)
}
// Focus window
window.makeKeyAndOrderFront(nil)
}
// We only want to hide the dock if it's not already going to be hidden automatically, and if
// it's on the same display as the ghostty window that we want to make fullscreen.
func shouldHideDock(screen: NSScreen) -> Bool {

View File

@ -16,7 +16,7 @@ fileprivate struct MetalViewRepresentable<V: MTKView>: NSViewRepresentable {
func makeNSView(context: Context) -> some NSView {
metalView
}
func updateNSView(_ view: NSViewType, context: Context) {
updateMetalView()
}

View File

@ -4,13 +4,13 @@ extension OSColor {
var isLightColor: Bool {
return self.luminance > 0.5
}
var luminance: Double {
var r: CGFloat = 0
var g: CGFloat = 0
var b: CGFloat = 0
var a: CGFloat = 0
// getRed:green:blue:alpha requires sRGB space
#if canImport(AppKit)
guard let rgb = self.usingColorSpace(.sRGB) else { return 0 }

View File

@ -7,7 +7,7 @@ extension SplitView {
let visibleSize: CGFloat
let invisibleSize: CGFloat
let color: Color
private var visibleWidth: CGFloat? {
switch (direction) {
case .horizontal:
@ -16,7 +16,7 @@ extension SplitView {
return nil
}
}
private var visibleHeight: CGFloat? {
switch (direction) {
case .horizontal:
@ -25,7 +25,7 @@ extension SplitView {
return visibleSize
}
}
private var invisibleWidth: CGFloat? {
switch (direction) {
case .horizontal:
@ -34,7 +34,7 @@ extension SplitView {
return nil
}
}
private var invisibleHeight: CGFloat? {
switch (direction) {
case .horizontal:

View File

@ -9,17 +9,17 @@ import Combine
struct SplitView<L: View, R: View>: View {
/// Direction of the split
let direction: SplitViewDirection
/// Divider color
let dividerColor: Color
/// If set, the split view supports programmatic resizing via events sent via the publisher.
/// Minimum increment (in points) that this split can be resized by, in
/// each direction. Both `height` and `width` should be whole numbers
/// greater than or equal to 1.0
let resizeIncrements: NSSize
let resizePublisher: PassthroughSubject<Double, Never>
/// The left and right views to render.
let left: L
let right: R
@ -34,13 +34,13 @@ struct SplitView<L: View, R: View>: View {
/// be used for getting a resize handle. The total width/height of the splitter is the sum of both.
private let splitterVisibleSize: CGFloat = 1
private let splitterInvisibleSize: CGFloat = 6
var body: some View {
GeometryReader { geo in
let leftRect = self.leftRect(for: geo.size)
let rightRect = self.rightRect(for: geo.size, leftRect: leftRect)
let splitterPoint = self.splitterPoint(for: geo.size, leftRect: leftRect)
ZStack(alignment: .topLeading) {
left
.frame(width: leftRect.size.width, height: leftRect.size.height)
@ -48,7 +48,7 @@ struct SplitView<L: View, R: View>: View {
right
.frame(width: rightRect.size.width, height: rightRect.size.height)
.offset(x: rightRect.origin.x, y: rightRect.origin.y)
Divider(direction: direction,
Divider(direction: direction,
visibleSize: splitterVisibleSize,
invisibleSize: splitterInvisibleSize,
color: dividerColor)
@ -60,11 +60,11 @@ struct SplitView<L: View, R: View>: View {
}
}
}
/// Initialize a split view. This view isn't programmatically resizable; it can only be resized
/// by manually dragging the divider.
init(_ direction: SplitViewDirection,
_ split: Binding<CGFloat>,
init(_ direction: SplitViewDirection,
_ split: Binding<CGFloat>,
dividerColor: Color,
@ViewBuilder left: (() -> L),
@ViewBuilder right: (() -> R)) {
@ -78,7 +78,7 @@ struct SplitView<L: View, R: View>: View {
right: right
)
}
/// Initialize a split view that supports programmatic resizing.
init(
_ direction: SplitViewDirection,
@ -97,7 +97,7 @@ struct SplitView<L: View, R: View>: View {
self.left = left()
self.right = right()
}
private func resize(for size: CGSize, amount: Double) {
let dim: CGFloat
switch (direction) {
@ -119,14 +119,14 @@ struct SplitView<L: View, R: View>: View {
case .horizontal:
let new = min(max(minSize, gesture.location.x), size.width - minSize)
split = new / size.width
case .vertical:
let new = min(max(minSize, gesture.location.y), size.height - minSize)
split = new / size.height
}
}
}
/// Calculates the bounding rect for the left view.
private func leftRect(for size: CGSize) -> CGRect {
// Initially the rect is the full size
@ -136,16 +136,16 @@ struct SplitView<L: View, R: View>: View {
result.size.width = result.size.width * split
result.size.width -= splitterVisibleSize / 2
result.size.width -= result.size.width.truncatingRemainder(dividingBy: self.resizeIncrements.width)
case .vertical:
result.size.height = result.size.height * split
result.size.height -= splitterVisibleSize / 2
result.size.height -= result.size.height.truncatingRemainder(dividingBy: self.resizeIncrements.height)
}
return result
}
/// Calculates the bounding rect for the right view.
private func rightRect(for size: CGSize, leftRect: CGRect) -> CGRect {
// Initially the rect is the full size
@ -157,22 +157,22 @@ struct SplitView<L: View, R: View>: View {
result.origin.x += leftRect.size.width
result.origin.x += splitterVisibleSize / 2
result.size.width -= result.origin.x
case .vertical:
result.origin.y += leftRect.size.height
result.origin.y += splitterVisibleSize / 2
result.size.height -= result.origin.y
}
return result
}
/// Calculates the point at which the splitter should be rendered.
private func splitterPoint(for size: CGSize, leftRect: CGRect) -> CGPoint {
switch (direction) {
case .horizontal:
return CGPoint(x: leftRect.size.width, y: size.height / 2)
case .vertical:
return CGPoint(x: size.width / 2, y: leftRect.size.height)
}

View File

@ -6,7 +6,7 @@ extension String {
}
return self.prefix(maxLength) + trailing
}
#if canImport(AppKit)
func temporaryFile(_ filename: String = "temp") -> URL {
let url = FileManager.default.temporaryDirectory