diff --git a/macos/Sources/App/iOS/iOSApp.swift b/macos/Sources/App/iOS/iOSApp.swift index ba1993296..4af94491c 100644 --- a/macos/Sources/App/iOS/iOSApp.swift +++ b/macos/Sources/App/iOS/iOSApp.swift @@ -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") diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 8b6b064a9..7618c4c72 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -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) diff --git a/macos/Sources/Features/About/AboutController.swift b/macos/Sources/Features/About/AboutController.swift index 897e2918b..d2ae68ea7 100644 --- a/macos/Sources/Features/About/AboutController.swift +++ b/macos/Sources/Features/About/AboutController.swift @@ -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() diff --git a/macos/Sources/Features/About/AboutView.swift b/macos/Sources/Features/About/AboutView.swift index 1ce507de1..02f899cc4 100644 --- a/macos/Sources/Features/About/AboutView.swift +++ b/macos/Sources/Features/About/AboutView.swift @@ -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) diff --git a/macos/Sources/Features/Services/ServiceProvider.swift b/macos/Sources/Features/Services/ServiceProvider.swift index ee52f3653..bb95cb55a 100644 --- a/macos/Sources/Features/Services/ServiceProvider.swift +++ b/macos/Sources/Features/Services/ServiceProvider.swift @@ -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 diff --git a/macos/Sources/Features/Settings/ConfigurationErrorsController.swift b/macos/Sources/Features/Settings/ConfigurationErrorsController.swift index b17ce5aab..b2550b94e 100644 --- a/macos/Sources/Features/Settings/ConfigurationErrorsController.swift +++ b/macos/Sources/Features/Settings/ConfigurationErrorsController.swift @@ -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() diff --git a/macos/Sources/Features/Settings/ConfigurationErrorsView.swift b/macos/Sources/Features/Settings/ConfigurationErrorsView.swift index dabfd1a3b..3ed84e3f0 100644 --- a/macos/Sources/Features/Settings/ConfigurationErrorsView.swift +++ b/macos/Sources/Features/Settings/ConfigurationErrorsView.swift @@ -6,7 +6,7 @@ protocol ConfigurationErrorsViewModel: ObservableObject { struct ConfigurationErrorsView: View { @ObservedObject var model: ViewModel - + var body: some View { VStack { HStack { @@ -15,7 +15,7 @@ struct ConfigurationErrorsView: 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: View { .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .topLeading) } - + Spacer() } .padding(.all) @@ -42,7 +42,7 @@ struct ConfigurationErrorsView: View { .background(Color(.controlBackgroundColor)) } } - + HStack { Spacer() Button("Ignore") { model.errors = [] } @@ -52,7 +52,7 @@ struct ConfigurationErrorsView: View { } .frame(minWidth: 480, maxWidth: 960, minHeight: 270) } - + private func reloadConfig() { guard let delegate = NSApplication.shared.delegate as? AppDelegate else { return } delegate.reloadConfig(nil) diff --git a/macos/Sources/Features/Settings/SettingsView.swift b/macos/Sources/Features/Settings/SettingsView.swift index cfc1256e4..82d24181a 100644 --- a/macos/Sources/Features/Settings/SettingsView.swift +++ b/macos/Sources/Features/Settings/SettingsView.swift @@ -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, " + diff --git a/macos/Sources/Features/Terminal/ErrorView.swift b/macos/Sources/Features/Terminal/ErrorView.swift index 3a921667d..31078ac04 100644 --- a/macos/Sources/Features/Terminal/ErrorView.swift +++ b/macos/Sources/Features/Terminal/ErrorView.swift @@ -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.") diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 729757dd2..1aa2d0c6f 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -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, diff --git a/macos/Sources/Features/Terminal/TerminalManager.swift b/macos/Sources/Features/Terminal/TerminalManager.swift index 9ca44312f..2559e1ec8 100644 --- a/macos/Sources/Features/Terminal/TerminalManager.swift +++ b/macos/Sources/Features/Terminal/TerminalManager.swift @@ -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) } } diff --git a/macos/Sources/Features/Terminal/TerminalRestorable.swift b/macos/Sources/Features/Terminal/TerminalRestorable.swift index b808e5701..1d1ae82d0 100644 --- a/macos/Sources/Features/Terminal/TerminalRestorable.swift +++ b/macos/Sources/Features/Terminal/TerminalRestorable.swift @@ -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, 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. diff --git a/macos/Sources/Features/Terminal/TerminalToolbar.swift b/macos/Sources/Features/Terminal/TerminalToolbar.swift index 38f6f1151..a9f3b530e 100644 --- a/macos/Sources/Features/Terminal/TerminalToolbar.swift +++ b/macos/Sources/Features/Terminal/TerminalToolbar.swift @@ -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 } diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index 8e1f0dbdd..8dadd1be2 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -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: 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: 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: 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)) diff --git a/macos/Sources/Features/Terminal/TerminalWindow.swift b/macos/Sources/Features/Terminal/TerminalWindow.swift index d259637ae..ffcc25eda 100644 --- a/macos/Sources/Features/Terminal/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/TerminalWindow.swift @@ -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) } diff --git a/macos/Sources/Features/Update/UpdateDelegate.swift b/macos/Sources/Features/Update/UpdateDelegate.swift index c462e180d..d010ddf2f 100644 --- a/macos/Sources/Features/Update/UpdateDelegate.swift +++ b/macos/Sources/Features/Update/UpdateDelegate.swift @@ -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 diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index b4fe17f86..69cbfbfc6 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -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?, state: UnsafeMutableRawPointer?, request: ghostty_clipboard_request_e ) {} - + static func writeClipboard( _ userdata: UnsafeMutableRawPointer?, string: UnsafePointer?, 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?, 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?, @@ -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?, 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.fromOpaque(userdata!).takeUnretainedValue() } - + #endif } } diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 63997e621..e917bf2c0 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -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.. 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? = 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? = 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? = 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? = 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? = 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; diff --git a/macos/Sources/Ghostty/Ghostty.Input.swift b/macos/Sources/Ghostty/Ghostty.Input.swift index 182e0dad1..d7fd96f12 100644 --- a/macos/Sources/Ghostty/Ghostty.Input.swift +++ b/macos/Sources/Ghostty/Ghostty.Input.swift @@ -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, ]; } - diff --git a/macos/Sources/Ghostty/Ghostty.Shell.swift b/macos/Sources/Ghostty/Ghostty.Shell.swift index 1f916802a..c37ef74bf 100644 --- a/macos/Sources/Ghostty/Ghostty.Shell.swift +++ b/macos/Sources/Ghostty/Ghostty.Shell.swift @@ -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 } } diff --git a/macos/Sources/Ghostty/Ghostty.SplitNode.swift b/macos/Sources/Ghostty/Ghostty.SplitNode.swift index 5dc1ec492..e5a86b4ca 100644 --- a/macos/Sources/Ghostty/Ghostty.SplitNode.swift +++ b/macos/Sources/Ghostty/Ghostty.SplitNode.swift @@ -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 diff --git a/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift b/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift index 88b352748..c9429ab79 100644 --- a/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift +++ b/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift @@ -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 { 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 { 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 diff --git a/macos/Sources/Ghostty/InspectorView.swift b/macos/Sources/Ghostty/InspectorView.swift index e0c8f600f..2d867e000 100644 --- a/macos/Sources/Ghostty/InspectorView.swift +++ b/macos/Sources/Ghostty/InspectorView.swift @@ -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, diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index 93399d4b5..475d68733 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -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") } diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 9ec41f95d..cd3967052 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -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 } } diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 1a68a2ea0..2051a5610 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -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 = [] - + 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 } } diff --git a/macos/Sources/Ghostty/SurfaceView_UIKit.swift b/macos/Sources/Ghostty/SurfaceView_UIKit.swift index ac5270c6a..b8c58caaa 100644 --- a/macos/Sources/Ghostty/SurfaceView_UIKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_UIKit.swift @@ -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) } diff --git a/macos/Sources/Helpers/CodableBridge.swift b/macos/Sources/Helpers/CodableBridge.swift index 7f0655f83..acc1da08a 100644 --- a/macos/Sources/Helpers/CodableBridge.swift +++ b/macos/Sources/Helpers/CodableBridge.swift @@ -4,16 +4,16 @@ import Cocoa class CodableBridge: 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") diff --git a/macos/Sources/Helpers/FullScreenHandler.swift b/macos/Sources/Helpers/FullScreenHandler.swift index 89923d5f9..c9d6e594e 100644 --- a/macos/Sources/Helpers/FullScreenHandler.swift +++ b/macos/Sources/Helpers/FullScreenHandler.swift @@ -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 { diff --git a/macos/Sources/Helpers/MetalView.swift b/macos/Sources/Helpers/MetalView.swift index ddbf00d1a..6579f8863 100644 --- a/macos/Sources/Helpers/MetalView.swift +++ b/macos/Sources/Helpers/MetalView.swift @@ -16,7 +16,7 @@ fileprivate struct MetalViewRepresentable: NSViewRepresentable { func makeNSView(context: Context) -> some NSView { metalView } - + func updateNSView(_ view: NSViewType, context: Context) { updateMetalView() } diff --git a/macos/Sources/Helpers/OSColor+Extension.swift b/macos/Sources/Helpers/OSColor+Extension.swift index d96e96aa9..a6d545f2b 100644 --- a/macos/Sources/Helpers/OSColor+Extension.swift +++ b/macos/Sources/Helpers/OSColor+Extension.swift @@ -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 } diff --git a/macos/Sources/Helpers/SplitView/SplitView.Divider.swift b/macos/Sources/Helpers/SplitView/SplitView.Divider.swift index 67aecb79d..f1a7f666d 100644 --- a/macos/Sources/Helpers/SplitView/SplitView.Divider.swift +++ b/macos/Sources/Helpers/SplitView/SplitView.Divider.swift @@ -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: diff --git a/macos/Sources/Helpers/SplitView/SplitView.swift b/macos/Sources/Helpers/SplitView/SplitView.swift index 3fad805e1..8ac2bc33f 100644 --- a/macos/Sources/Helpers/SplitView/SplitView.swift +++ b/macos/Sources/Helpers/SplitView/SplitView.swift @@ -9,17 +9,17 @@ import Combine struct SplitView: 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 - + /// The left and right views to render. let left: L let right: R @@ -34,13 +34,13 @@ struct SplitView: 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: 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: 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, + init(_ direction: SplitViewDirection, + _ split: Binding, dividerColor: Color, @ViewBuilder left: (() -> L), @ViewBuilder right: (() -> R)) { @@ -78,7 +78,7 @@ struct SplitView: View { right: right ) } - + /// Initialize a split view that supports programmatic resizing. init( _ direction: SplitViewDirection, @@ -97,7 +97,7 @@ struct SplitView: 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: 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: 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: 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) } diff --git a/macos/Sources/Helpers/String+Extension.swift b/macos/Sources/Helpers/String+Extension.swift index f39ef8d3d..0c1c4fe91 100644 --- a/macos/Sources/Helpers/String+Extension.swift +++ b/macos/Sources/Helpers/String+Extension.swift @@ -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