mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-07-14 15:56:13 +03:00
Merge pull request #2129 from pnodet/patch-4
style(macos): cleanup trailing spaces
This commit is contained in:
@ -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")
|
||||
|
@ -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)
|
||||
|
@ -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()
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
|
@ -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)
|
||||
|
@ -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, " +
|
||||
|
@ -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.")
|
||||
|
@ -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,
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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.
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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))
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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")
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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")
|
||||
|
@ -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 {
|
||||
|
@ -16,7 +16,7 @@ fileprivate struct MetalViewRepresentable<V: MTKView>: NSViewRepresentable {
|
||||
func makeNSView(context: Context) -> some NSView {
|
||||
metalView
|
||||
}
|
||||
|
||||
|
||||
func updateNSView(_ view: NSViewType, context: Context) {
|
||||
updateMetalView()
|
||||
}
|
||||
|
@ -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 }
|
||||
|
@ -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:
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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
|
||||
|
Reference in New Issue
Block a user