macos: merge AppController and AppDelegate, organize groups

This commit is contained in:
Mitchell Hashimoto
2023-08-04 17:05:52 -07:00
parent b56ffa6285
commit bf190ba442
10 changed files with 160 additions and 149 deletions

View File

@@ -1,36 +1,82 @@
import OSLog
import SwiftUI
import AppKit
import OSLog
import GhosttyKit
class GhosttyAppController: NSObject {
@IBOutlet weak fileprivate var mainMenu: NSMenu!
@NSApplicationMain
class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
// The application logger. We should probably move this at some point to a dedicated
// class/struct but for now it lives here! 🤷
static let logger = Logger(
subsystem: Bundle.main.bundleIdentifier!,
category: String(describing: AppDelegate.self)
)
// confirmQuit published so other views can check whether quit needs to be confirmed.
@Published var confirmQuit: Bool = false
/// The ghostty global state. Only one per process.
var ghostty: Ghostty.AppState = Ghostty.AppState()
private var ghostty: Ghostty.AppState = Ghostty.AppState()
/// Manages windows and tabs, ensuring they're allocated/deallocated correctly
var windowService: WindowService!
private var windowManager: PrimaryWindowManager!
override init() {
super.init()
// We're initialized through the MainMenu, because we're a referenced objected.
// So when we're here, we initialize the WindowService, which will open first window.
windowService = WindowService(ghostty: self.ghostty)
windowManager = PrimaryWindowManager(ghostty: self.ghostty)
}
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,
])
// Let's launch our first window.
// TODO: we should detect if we restored windows and if so not launch a new window.
windowManager.addInitialWindow()
}
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.
if let event = NSAppleEventManager.shared().currentAppleEvent {
if let why = event.attributeDescriptor(forKeyword: AEKeyword("why?")!) {
switch (why.typeCodeValue) {
case kAEShutDown:
fallthrough
case kAERestart:
fallthrough
case kAEReallyLogOut:
return .terminateNow
default:
break
}
}
}
// We have some visible window, and all our windows will watch the confirmQuit.
confirmQuit = true
return .terminateLater
}
@IBAction func newWindow(_ sender: Any?) {
windowService.addNewWindow()
windowManager.addNewWindow()
}
@IBAction func newTab(_ sender: Any?) {
windowService.addNewTab()
windowManager.addNewTab()
}
@IBAction func closeWindow(_ sender: Any) {
@@ -48,7 +94,7 @@ class GhosttyAppController: NSObject {
}
private func focusedSurface() -> ghostty_surface_t? {
guard let window = NSApp.keyWindow as? CustomWindow else { return nil }
guard let window = NSApp.keyWindow as? PrimaryWindow else { return nil }
return window.focusedSurfaceWrapper.surface
}
@@ -91,48 +137,3 @@ class GhosttyAppController: NSObject {
ghostty.splitMoveFocus(surface: surface, direction: direction)
}
}
class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
// confirmQuit published so other views can check whether quit needs to be confirmed.
@Published var confirmQuit: Bool = false
func applicationDidFinishLaunching(_ notification: Notification) {
UserDefaults.standard.register(defaults: [
// Disable this so that repeated key events make it through to our terminal views.
"ApplePressAndHoldEnabled": false,
])
}
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.
if let event = NSAppleEventManager.shared().currentAppleEvent {
if let why = event.attributeDescriptor(forKeyword: AEKeyword("why?")!) {
switch (why.typeCodeValue) {
case kAEShutDown:
fallthrough
case kAERestart:
fallthrough
case kAEReallyLogOut:
return .terminateNow
default:
break
}
}
}
// We have some visible window, and all our windows will watch the confirmQuit.
confirmQuit = true
return .terminateLater
}
}

View File

@@ -8,18 +8,20 @@ class FocusedSurfaceWrapper {
var surface: ghostty_surface_t?
}
// CustomWindow exists purely so we can override canBecomeKey and canBecomeMain.
// We need that for the non-native fullscreen.
// If we don't use `CustomWindow` we'll get warning messages in the output to say that
// `makeKeyWindow` was called and returned NO.
class CustomWindow: NSWindow {
// PrimaryWindow is the primary window you'd associate with a terminal: the window
// that contains one or more terminals (splits, and such).
//
// We need to subclass NSWindow so that we can override some methods for features
// such as non-native fullscreen.
class PrimaryWindow: NSWindow {
var focusedSurfaceWrapper: FocusedSurfaceWrapper = FocusedSurfaceWrapper()
static func create(ghostty: Ghostty.AppState, appDelegate: AppDelegate) -> CustomWindow {
let window = CustomWindow(
static func create(ghostty: Ghostty.AppState, appDelegate: AppDelegate) -> PrimaryWindow {
let window = PrimaryWindow(
contentRect: NSRect(x: 0, y: 0, width: 800, height: 600),
styleMask: [.titled, .closable, .miniaturizable, .resizable],
backing: .buffered, defer: false)
backing: .buffered,
defer: false)
window.center()
window.contentView = NSHostingView(rootView: ContentView(
ghostty: ghostty,

View File

@@ -0,0 +1,14 @@
import Cocoa
class PrimaryWindowController: NSWindowController {
// 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.
static var lastCascadePoint = NSPoint(x: 0, y: 0)
static func create(ghosttyApp: Ghostty.AppState, appDelegate: AppDelegate) -> PrimaryWindowController {
let window = PrimaryWindow.create(ghostty: ghosttyApp, appDelegate: appDelegate)
lastCascadePoint = window.cascadeTopLeft(from: lastCascadePoint)
return PrimaryWindowController(window: window)
}
}

View File

@@ -1,17 +1,20 @@
import Cocoa
import Combine
// WindowService manages the windows and tabs in the application.
// It keeps references to windows and cleans them up when they're cloned.
// PrimaryWindowManager manages the windows and tabs in the primary window
// of the application. It keeps references to windows and cleans them up when
// they're cloned.
//
// If we ever have multiple tabbed window types we can make this generic but
// right now only our primary window is ever duplicated or tabbed so we're not
// doing that.
//
// It is based on the patterns presented in this blog post:
// https://christiantietze.de/posts/2019/07/nswindow-tabbing-multiple-nswindowcontroller/
class WindowService {
class PrimaryWindowManager {
struct ManagedWindow {
let windowController: NSWindowController
let window: NSWindow
let closePublisher: AnyCancellable
}
@@ -20,11 +23,10 @@ class WindowService {
init(ghostty: Ghostty.AppState) {
self.ghostty = ghostty
addInitialWindow()
}
private func addInitialWindow() {
/// Add the initial window for the application. This should only be called once from the AppDelegate.
func addInitialWindow() {
guard let controller = createWindowController() else { return }
controller.showWindow(self)
let result = addManagedWindow(windowController: controller)
@@ -54,12 +56,12 @@ class WindowService {
return (mainManagedWindow ?? managedWindows.first).map { $0.window }
}
private func createWindowController() -> WindowController? {
private func createWindowController() -> PrimaryWindowController? {
guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else { return nil }
return WindowController.create(ghosttyApp: self.ghostty, appDelegate: appDelegate)
return PrimaryWindowController.create(ghosttyApp: self.ghostty, appDelegate: appDelegate)
}
private func addManagedWindow(windowController: WindowController) -> ManagedWindow? {
private func addManagedWindow(windowController: PrimaryWindowController) -> ManagedWindow? {
guard let window = windowController.window else { return nil }
let pubClose = NotificationCenter.default.publisher(for: NSWindow.willCloseNotification, object: window)

View File

@@ -38,7 +38,7 @@ extension Ghostty {
init() {
// Initialize ghostty global state. This happens once per process.
guard ghostty_init() == GHOSTTY_SUCCESS else {
GhosttyAppController.logger.critical("ghostty_init failed")
AppDelegate.logger.critical("ghostty_init failed")
readiness = .error
return
}
@@ -68,7 +68,7 @@ extension Ghostty {
// Create the ghostty app.
guard let app = ghostty_app_new(&runtime_cfg, cfg) else {
GhosttyAppController.logger.critical("ghostty_app_new failed")
AppDelegate.logger.critical("ghostty_app_new failed")
readiness = .error
return
}
@@ -87,7 +87,7 @@ extension Ghostty {
static func reloadConfig() -> ghostty_config_t? {
// Initialize the global configuration.
guard let cfg = ghostty_config_new() else {
GhosttyAppController.logger.critical("ghostty_config_new failed")
AppDelegate.logger.critical("ghostty_config_new failed")
return nil
}
@@ -189,7 +189,7 @@ extension Ghostty {
static func reloadConfig(_ userdata: UnsafeMutableRawPointer?) -> ghostty_config_t? {
guard let newConfig = AppState.reloadConfig() else {
GhosttyAppController.logger.warning("failed to reload configuration")
AppDelegate.logger.warning("failed to reload configuration")
return nil
}

View File

@@ -5,15 +5,20 @@
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="21701"/>
</dependencies>
<objects>
<customObject id="-2" userLabel="File's Owner" customClass="NSApplication"/>
<customObject id="-2" userLabel="File's Owner" customClass="NSApplication">
<connections>
<outlet property="delegate" destination="bbz-4X-AYv" id="4pZ-gB-Uf0"/>
</connections>
</customObject>
<customObject id="-1" userLabel="First Responder" customClass="FirstResponder"/>
<customObject id="-3" userLabel="Application" customClass="NSObject"/>
<customObject id="bbz-4X-AYv" userLabel="AppDelegate" customClass="AppDelegate" customModule="Ghostty" customModuleProvider="target"/>
<customObject id="YLy-65-1bz" customClass="NSFontManager"/>
<menu title="Main Menu" systemMenu="main" id="AYu-sK-qS6">
<items>
<menuItem title="NewApplication" id="1Xt-HY-uBw">
<menuItem title="Ghostty" id="1Xt-HY-uBw">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="NewApplication" systemMenu="apple" id="uQy-DD-JDr">
<menu key="submenu" title="Ghostty" systemMenu="apple" id="uQy-DD-JDr">
<items>
<menuItem title="About Ghostty" id="5kV-Vb-QxS">
<modifierMask key="keyEquivalentModifierMask"/>
@@ -56,34 +61,34 @@
<items>
<menuItem title="New Window" keyEquivalent="n" id="Was-JA-tGl">
<connections>
<action selector="newWindow:" target="IUl-M9-b48" id="hDE-pE-3Ml"/>
<action selector="newWindow:" target="bbz-4X-AYv" id="NnC-l5-DUY"/>
</connections>
</menuItem>
<menuItem title="New Tab" keyEquivalent="t" id="uTG-Vz-hJU">
<connections>
<action selector="newTab:" target="IUl-M9-b48" id="MHd-lY-6H5"/>
<action selector="newTab:" target="bbz-4X-AYv" id="cxO-CS-TJq"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="m54-Is-iLE"/>
<menuItem title="Split Horizontally" keyEquivalent="d" id="VUR-Ld-nLx">
<connections>
<action selector="splitHorizontally:" target="IUl-M9-b48" id="0y5-Ge-OF5"/>
<action selector="splitHorizontally:" target="bbz-4X-AYv" id="QT1-Yt-gYJ"/>
</connections>
</menuItem>
<menuItem title="Split Vertically" keyEquivalent="D" id="UDZ-4y-6xL">
<connections>
<action selector="splitVertically:" target="IUl-M9-b48" id="QZ1-5M-OQG"/>
<action selector="splitVertically:" target="bbz-4X-AYv" id="ZZF-3f-OwW"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="sjq-M1-UGS"/>
<menuItem title="Close" keyEquivalent="w" id="DVo-aG-piG">
<connections>
<action selector="close:" target="IUl-M9-b48" id="Cc3-qR-k0Z"/>
<action selector="close:" target="bbz-4X-AYv" id="Szc-Fu-9yk"/>
</connections>
</menuItem>
<menuItem title="Close Window" keyEquivalent="W" id="W5w-UZ-crk">
<connections>
<action selector="closeWindow:" target="IUl-M9-b48" id="Yaz-fy-DFE"/>
<action selector="closeWindow:" target="bbz-4X-AYv" id="j4w-Nd-9bO"/>
</connections>
</menuItem>
</items>
@@ -114,12 +119,12 @@
<menuItem isSeparatorItem="YES" id="rlu-tP-x0P"/>
<menuItem title="Select Previous Split" keyEquivalent="[" id="Lic-px-1wg">
<connections>
<action selector="splitMoveFocusPrevious:" target="IUl-M9-b48" id="d37-lc-L2w"/>
<action selector="splitMoveFocusPrevious:" target="bbz-4X-AYv" id="mOs-gG-dAC"/>
</connections>
</menuItem>
<menuItem title="Select Next Split" keyEquivalent="]" id="bD7-ei-wKU">
<connections>
<action selector="splitMoveFocusNext:" target="IUl-M9-b48" id="eJ4-vo-aSM"/>
<action selector="splitMoveFocusNext:" target="bbz-4X-AYv" id="rU6-Vw-DoW"/>
</connections>
</menuItem>
<menuItem title="Select Split" id="dos-9S-LXC">
@@ -129,25 +134,25 @@
<menuItem title="Select Split Above" keyEquivalent="" id="0yU-hC-8xF">
<modifierMask key="keyEquivalentModifierMask" option="YES" command="YES"/>
<connections>
<action selector="splitMoveFocusAbove:" target="IUl-M9-b48" id="Ngp-ty-rtO"/>
<action selector="splitMoveFocusAbove:" target="bbz-4X-AYv" id="HDw-f2-RJY"/>
</connections>
</menuItem>
<menuItem title="Select Split Below" keyEquivalent="" id="QDz-d9-CBr">
<modifierMask key="keyEquivalentModifierMask" option="YES" command="YES"/>
<connections>
<action selector="splitMoveFocusBelow:" target="IUl-M9-b48" id="NZF-SR-DRF"/>
<action selector="splitMoveFocusBelow:" target="bbz-4X-AYv" id="fmW-hZ-uOA"/>
</connections>
</menuItem>
<menuItem title="Select Split Left" keyEquivalent="" id="cTK-oy-KuV">
<modifierMask key="keyEquivalentModifierMask" option="YES" command="YES"/>
<connections>
<action selector="splitMoveFocusLeft:" target="IUl-M9-b48" id="lqR-BO-6Xc"/>
<action selector="splitMoveFocusLeft:" target="bbz-4X-AYv" id="N1i-a2-7N5"/>
</connections>
</menuItem>
<menuItem title="Select Split Right" keyEquivalent="" id="upj-mc-L7X">
<modifierMask key="keyEquivalentModifierMask" option="YES" command="YES"/>
<connections>
<action selector="splitMoveFocusRight:" target="IUl-M9-b48" id="gjS-dq-5ll"/>
<action selector="splitMoveFocusRight:" target="bbz-4X-AYv" id="Pgi-df-84r"/>
</connections>
</menuItem>
</items>
@@ -171,10 +176,5 @@
</items>
<point key="canvasLocation" x="139" y="154"/>
</menu>
<customObject id="IUl-M9-b48" customClass="GhosttyAppController" customModule="Ghostty" customModuleProvider="target">
<connections>
<outlet property="mainMenu" destination="AYu-sK-qS6" id="VpF-hi-cLE"/>
</connections>
</customObject>
</objects>
</document>

View File

@@ -1,11 +0,0 @@
import Cocoa
class WindowController: NSWindowController {
static var lastCascadePoint = NSPoint(x: 0, y: 0)
static func create(ghosttyApp: Ghostty.AppState, appDelegate: AppDelegate) -> WindowController {
let window = CustomWindow.create(ghostty: ghosttyApp, appDelegate: appDelegate)
lastCascadePoint = window.cascadeTopLeft(from: lastCascadePoint)
return WindowController(window: window)
}
}

View File

@@ -1,7 +0,0 @@
import AppKit
let app = NSApplication.shared
let delegate = AppDelegate()
app.delegate = delegate
_ = NSApplicationMain(CommandLine.argc, CommandLine.unsafeArgv)