mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-04-12 10:48:39 +03:00

Previously, we would access the `ghostty.config` object from anywhere. The issue with this is that memory lifetime access to the underlying `ghostty_config_t` was messy. It was easy when the apprt owned every reference but since automatic theme changes were implemented, this isn't always true anymore. To fix this, we move to the same pattern we use internally in the core of ghostty: whenever the config changes, we handle an event, derive our desired values out of the config (copy them), and then let the caller free the config if they want to. This way, we can be sure that any information we need from the config is always owned by us.
639 lines
25 KiB
Swift
639 lines
25 KiB
Swift
import Foundation
|
|
import Cocoa
|
|
import SwiftUI
|
|
import GhosttyKit
|
|
|
|
/// A classic, tabbed terminal experience.
|
|
class TerminalController: BaseTerminalController {
|
|
override var windowNibName: NSNib.Name? { "Terminal" }
|
|
|
|
/// 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
|
|
|
|
/// The configuration derived from the Ghostty config so we don't need to rely on references.
|
|
private var derivedConfig: DerivedConfig
|
|
|
|
init(_ ghostty: Ghostty.App,
|
|
withBaseConfig base: Ghostty.SurfaceConfiguration? = nil,
|
|
withSurfaceTree tree: Ghostty.SplitNode? = nil
|
|
) {
|
|
// 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 ?? "") == ""
|
|
|
|
// Setup our initial derived config based on the current app config
|
|
self.derivedConfig = DerivedConfig(ghostty.config)
|
|
|
|
super.init(ghostty, baseConfig: base, surfaceTree: tree)
|
|
|
|
// Setup our notifications for behaviors
|
|
let center = NotificationCenter.default
|
|
center.addObserver(
|
|
self,
|
|
selector: #selector(onToggleFullscreen),
|
|
name: Ghostty.Notification.ghosttyToggleFullscreen,
|
|
object: nil)
|
|
center.addObserver(
|
|
self,
|
|
selector: #selector(onMoveTab),
|
|
name: .ghosttyMoveTab,
|
|
object: nil)
|
|
center.addObserver(
|
|
self,
|
|
selector: #selector(onGotoTab),
|
|
name: Ghostty.Notification.ghosttyGotoTab,
|
|
object: nil)
|
|
center.addObserver(
|
|
self,
|
|
selector: #selector(ghosttyConfigDidChange(_:)),
|
|
name: .ghosttyConfigDidChange,
|
|
object: nil
|
|
)
|
|
center.addObserver(
|
|
self,
|
|
selector: #selector(onFrameDidChange),
|
|
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: Base Controller Overrides
|
|
|
|
override func surfaceTreeDidChange(from: Ghostty.SplitNode?, to: Ghostty.SplitNode?) {
|
|
super.surfaceTreeDidChange(from: from, to: to)
|
|
|
|
// If our surface tree is now nil then we close our window.
|
|
if (to == nil) {
|
|
self.window?.close()
|
|
}
|
|
}
|
|
|
|
//MARK: - Methods
|
|
|
|
@objc private func ghosttyConfigDidChange(_ notification: Notification) {
|
|
// We only care if the configuration is a global configuration, not a
|
|
// surface-specific one.
|
|
guard notification.object == nil else { return }
|
|
|
|
// Get our managed configuration object out
|
|
guard let config = notification.userInfo?[
|
|
Notification.Name.GhosttyConfigChangeKey
|
|
] as? Ghostty.Config else { return }
|
|
|
|
// Update our derived config
|
|
self.derivedConfig = DerivedConfig(config)
|
|
|
|
guard let window = window as? TerminalWindow else { return }
|
|
window.focusFollowsMouse = config.focusFollowsMouse
|
|
syncAppearance(config)
|
|
}
|
|
|
|
/// 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
|
|
/// with the mouse.
|
|
func relabelTabs() {
|
|
// Reset this to false. It'll be set back to true later.
|
|
tabListenForFrame = false
|
|
|
|
guard let windows = self.window?.tabbedWindows as? [TerminalWindow] else { return }
|
|
|
|
// 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.
|
|
guard tab <= 9 else {
|
|
window.keyEquivalent = ""
|
|
continue
|
|
}
|
|
|
|
let action = "goto_tab:\(tab)"
|
|
if let equiv = ghostty.config.keyEquivalent(for: action) {
|
|
window.keyEquivalent = "\(equiv)"
|
|
} else {
|
|
window.keyEquivalent = ""
|
|
}
|
|
}
|
|
}
|
|
|
|
private func fixTabBar() {
|
|
// We do this to make sure that the tab bar will always re-composite. If we don't,
|
|
// then the it will "drag" pieces of the background with it when a transparent
|
|
// window is moved around.
|
|
//
|
|
// There might be a better way to make the tab bar "un-lazy", but I can't find it.
|
|
if let window = window, !window.isOpaque {
|
|
window.isOpaque = true
|
|
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.
|
|
// as far as I can tell for when a tab is manually reordered with the
|
|
// mouse in a macOS-native tab group, so the way we detect it is setting
|
|
// the accessoryView "postsFrameChangedNotification" to true, listening
|
|
// for the view frame to change, comparing the windows list, and
|
|
// relabeling the tabs.
|
|
guard tabListenForFrame else { return }
|
|
guard let v = self.window?.tabbedWindows?.hashValue else { return }
|
|
guard tabWindowsHash != v else { return }
|
|
tabWindowsHash = v
|
|
self.relabelTabs()
|
|
}
|
|
|
|
private func syncAppearance(_ config: Ghostty.Config) {
|
|
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
|
|
// APIs such as window blur have no effect unless the window is visible.
|
|
guard window.isVisible else {
|
|
// Weak window so that if the window changes or is destroyed we aren't holding a ref
|
|
DispatchQueue.main.async { [weak self] in self?.syncAppearance(config) }
|
|
return
|
|
}
|
|
|
|
// Set the font for the window and tab titles.
|
|
if let titleFontName = 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 (config.backgroundOpacity < 1) {
|
|
window.isOpaque = false
|
|
|
|
// This is weird, but we don't use ".clear" because this creates a look that
|
|
// 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
|
|
window.backgroundColor = .windowBackgroundColor
|
|
}
|
|
|
|
window.hasShadow = config.macosWindowShadow
|
|
|
|
guard window.hasStyledTabs else { return }
|
|
|
|
// The titlebar is always updated. We don't need to worry about opacity
|
|
// because we handle it here.
|
|
let backgroundColor = OSColor(config.backgroundColor)
|
|
window.titlebarColor = backgroundColor.withAlphaComponent(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()
|
|
}
|
|
}
|
|
|
|
//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 }
|
|
|
|
// I copy this because we may change the source in the future but also because
|
|
// I regularly audit our codebase for "ghostty.config" access because generally
|
|
// you shouldn't use it. Its safe in this case because for a new window we should
|
|
// use whatever the latest app-level config is.
|
|
let config = ghostty.config
|
|
|
|
// 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 (!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
|
|
// Ghostty defaults to sRGB but this can be overridden.
|
|
switch (config.windowColorspace) {
|
|
case "display-p3":
|
|
window.colorSpace = .displayP3
|
|
case "srgb":
|
|
fallthrough
|
|
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 {
|
|
if let initialSize = leaf.surface.initialSize,
|
|
let screen = window.screen ?? NSScreen.main {
|
|
// Setup our frame. We need to first subtract the views frame so that we can
|
|
// just get the chrome frame so that we only affect the surface view size.
|
|
var frame = window.frame
|
|
frame.size.width -= leaf.surface.frame.size.width
|
|
frame.size.height -= leaf.surface.frame.size.height
|
|
frame.size.width += min(initialSize.width, screen.frame.width)
|
|
frame.size.height += min(initialSize.height, screen.frame.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()
|
|
|
|
// Make sure our theme is set on the window so styling is correct.
|
|
if let windowTheme = config.windowTheme {
|
|
window.windowTheme = .init(rawValue: windowTheme)
|
|
}
|
|
|
|
// Handle titlebar tabs config option. Something about what we do while setting up the
|
|
// titlebar tabs interferes with the window restore process unless window.tabbingMode
|
|
// is set to .preferred, so we set it, and switch back to automatic as soon as we can.
|
|
if (config.macosTitlebarStyle == "tabs") {
|
|
window.tabbingMode = .preferred
|
|
window.titlebarTabs = true
|
|
DispatchQueue.main.async {
|
|
window.tabbingMode = .automatic
|
|
}
|
|
} else if (config.macosTitlebarStyle == "transparent") {
|
|
window.transparentTabs = true
|
|
}
|
|
|
|
if window.hasStyledTabs {
|
|
// Set the background color of the window
|
|
let backgroundColor = NSColor(config.backgroundColor)
|
|
window.backgroundColor = backgroundColor
|
|
|
|
// This makes sure our titlebar renders correctly when there is a transparent background
|
|
window.titlebarColor = backgroundColor.withAlphaComponent(config.backgroundOpacity)
|
|
}
|
|
|
|
// Initialize our content view to the SwiftUI root
|
|
window.contentView = NSHostingView(rootView: TerminalView(
|
|
ghostty: self.ghostty,
|
|
viewModel: self,
|
|
delegate: self
|
|
))
|
|
|
|
// If our titlebar style is "hidden" we adjust the style appropriately
|
|
if (config.macosTitlebarStyle == "hidden") {
|
|
window.styleMask = [
|
|
// We need `titled` in the mask to get the normal window frame
|
|
.titled,
|
|
|
|
// Full size content view so we can extend
|
|
// content in to the hidden titlebar's area
|
|
.fullSizeContentView,
|
|
|
|
.resizable,
|
|
.closable,
|
|
.miniaturizable,
|
|
]
|
|
|
|
// Hide the title
|
|
window.titleVisibility = .hidden
|
|
window.titlebarAppearsTransparent = true
|
|
|
|
// Hide the traffic lights (window control buttons)
|
|
window.standardWindowButton(.closeButton)?.isHidden = true
|
|
window.standardWindowButton(.miniaturizeButton)?.isHidden = true
|
|
window.standardWindowButton(.zoomButton)?.isHidden = true
|
|
|
|
// Disallow tabbing if the titlebar is hidden, since that will (should) also hide the tab bar.
|
|
window.tabbingMode = .disallowed
|
|
|
|
// Nuke it from orbit -- hide the titlebar container entirely, just in case. There are
|
|
// some operations that appear to bring back the titlebar visibility so this ensures
|
|
// it is gone forever.
|
|
if let themeFrame = window.contentView?.superview,
|
|
let titleBarContainer = themeFrame.firstDescendant(withClassName: "NSTitlebarContainerView") {
|
|
titleBarContainer.isHidden = true
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
// Example scenarios where this happens:
|
|
// - When the system user tabbing preference is "always"
|
|
// - When the "+" button in the tab bar is clicked
|
|
//
|
|
// We don't run this logic in fullscreen because in fullscreen this will end up
|
|
// removing the window and putting it into its own dedicated fullscreen, which is not
|
|
// the expected or desired behavior of anyone I've found.
|
|
if (!window.styleMask.contains(.fullScreen)) {
|
|
// If we have more than 1 window in our tab group we know we're a new window.
|
|
// Since Ghostty manages tabbing manually this will never be more than one
|
|
// at this point in the AppKit lifecycle (we add to the group after this).
|
|
if let tabGroup = window.tabGroup, tabGroup.windows.count > 1 {
|
|
window.tabGroup?.removeWindow(window)
|
|
}
|
|
}
|
|
|
|
window.focusFollowsMouse = config.focusFollowsMouse
|
|
|
|
// Apply any additional appearance-related properties to the new window.
|
|
syncAppearance(config)
|
|
}
|
|
|
|
// 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.
|
|
guard let surface = self.focusedSurface?.surface else { return }
|
|
ghostty.newTab(surface: surface)
|
|
}
|
|
|
|
//MARK: - NSWindowDelegate
|
|
|
|
override func windowWillClose(_ notification: Notification) {
|
|
super.windowWillClose(notification)
|
|
self.relabelTabs()
|
|
}
|
|
|
|
override func windowDidBecomeKey(_ notification: Notification) {
|
|
super.windowDidBecomeKey(notification)
|
|
self.relabelTabs()
|
|
self.fixTabBar()
|
|
}
|
|
|
|
override func windowDidMove(_ notification: Notification) {
|
|
super.windowDidMove(notification)
|
|
self.fixTabBar()
|
|
}
|
|
|
|
// Called when the window will be encoded. We handle the data encoding here in the
|
|
// window controller.
|
|
func window(_ window: NSWindow, willEncodeRestorableState state: NSCoder) {
|
|
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 override func closeWindow(_ sender: Any) {
|
|
guard let window = window else { return }
|
|
guard let tabGroup = window.tabGroup else {
|
|
// No tabs, no tab group, just perform a normal close.
|
|
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 {
|
|
guard let c = tabWindow.windowController as? TerminalController else { continue }
|
|
if (c.surfaceTree?.needsConfirmQuit() ?? false) {
|
|
needsConfirm = true
|
|
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()
|
|
alert.messageText = "Close Window?"
|
|
alert.informativeText = "All terminal sessions in this window will be terminated."
|
|
alert.addButton(withTitle: "Close Window")
|
|
alert.addButton(withTitle: "Cancel")
|
|
alert.alertStyle = .warning
|
|
alert.beginSheetModal(for: window, completionHandler: { response in
|
|
if (response == .alertFirstButtonReturn) {
|
|
for tabWindow in tabGroup.windows {
|
|
tabWindow.close()
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
@IBAction func toggleGhosttyFullScreen(_ sender: Any) {
|
|
guard let surface = focusedSurface?.surface else { return }
|
|
ghostty.toggleFullscreen(surface: surface)
|
|
}
|
|
|
|
@IBAction func toggleTerminalInspector(_ sender: Any) {
|
|
guard let surface = focusedSurface?.surface else { return }
|
|
ghostty.toggleTerminalInspector(surface: surface)
|
|
}
|
|
|
|
//MARK: - TerminalViewDelegate
|
|
|
|
override func titleDidChange(to: String) {
|
|
super.titleDidChange(to: to)
|
|
|
|
guard let window = window as? TerminalWindow else { return }
|
|
|
|
// Custom toolbar-based title used when titlebar tabs are enabled.
|
|
if let toolbar = window.toolbar as? TerminalToolbar {
|
|
if (window.titlebarTabs || derivedConfig.macosTitlebarStyle == "hidden") {
|
|
// Updating the title text as above automatically reveals the
|
|
// native title view in macOS 15.0 and above. Since we're using
|
|
// a custom view instead, we need to re-hide it.
|
|
window.titleVisibility = .hidden
|
|
}
|
|
toolbar.titleText = to
|
|
}
|
|
}
|
|
|
|
override func surfaceTreeDidChange() {
|
|
// Whenever our surface tree changes in any way (new split, close split, etc.)
|
|
// we want to invalidate our state.
|
|
invalidateRestorableState()
|
|
}
|
|
|
|
override func zoomStateDidChange(to: Bool) {
|
|
guard let window = window as? TerminalWindow else { return }
|
|
window.surfaceIsZoomed = to
|
|
}
|
|
|
|
//MARK: - Notifications
|
|
|
|
@objc private func onMoveTab(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 move action
|
|
guard let action = notification.userInfo?[Notification.Name.GhosttyMoveTabKey] as? Ghostty.Action.MoveTab else { return }
|
|
guard action.amount != 0 else { return }
|
|
|
|
// Determine our current selected index
|
|
guard let windowController = window.windowController else { return }
|
|
guard let tabGroup = windowController.window?.tabGroup else { return }
|
|
guard let selectedWindow = tabGroup.selectedWindow else { return }
|
|
let tabbedWindows = tabGroup.windows
|
|
guard tabbedWindows.count > 0 else { return }
|
|
guard let selectedIndex = tabbedWindows.firstIndex(where: { $0 == selectedWindow }) else { return }
|
|
|
|
// Determine the final index we want to insert our tab
|
|
let finalIndex: Int
|
|
if action.amount < 0 {
|
|
finalIndex = selectedIndex - min(selectedIndex, -action.amount)
|
|
} else {
|
|
let remaining: Int = tabbedWindows.count - 1 - selectedIndex
|
|
finalIndex = selectedIndex + min(remaining, action.amount)
|
|
}
|
|
|
|
// If our index is the same we do nothing
|
|
guard finalIndex != selectedIndex else { return }
|
|
|
|
// Get our parent
|
|
let parent = tabbedWindows[finalIndex]
|
|
|
|
// Move our current selected window to the proper index
|
|
tabGroup.removeWindow(selectedWindow)
|
|
parent.addTabbedWindow(selectedWindow, ordered: action.amount < 0 ? .below : .above)
|
|
selectedWindow.makeKeyAndOrderFront(nil)
|
|
}
|
|
|
|
@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 tabEnumAny = notification.userInfo?[Ghostty.Notification.GotoTabKey] else { return }
|
|
guard let tabEnum = tabEnumAny as? ghostty_action_goto_tab_e else { return }
|
|
let tabIndex: Int32 = tabEnum.rawValue
|
|
|
|
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_GOTO_TAB_PREVIOUS.rawValue) {
|
|
if (selectedIndex == 0) {
|
|
finalIndex = tabbedWindows.count - 1
|
|
} else {
|
|
finalIndex = selectedIndex - 1
|
|
}
|
|
} else if (tabIndex == GHOSTTY_GOTO_TAB_NEXT.rawValue) {
|
|
if (selectedIndex == tabbedWindows.count - 1) {
|
|
finalIndex = 0
|
|
} else {
|
|
finalIndex = selectedIndex + 1
|
|
}
|
|
} else if (tabIndex == GHOSTTY_GOTO_TAB_LAST.rawValue) {
|
|
finalIndex = tabbedWindows.count - 1
|
|
} else {
|
|
return
|
|
}
|
|
} else {
|
|
// The configured value is 1-indexed.
|
|
guard tabIndex >= 1 else { return }
|
|
|
|
// If our index is outside our boundary then we use the max
|
|
finalIndex = min(Int(tabIndex - 1), tabbedWindows.count - 1)
|
|
}
|
|
|
|
guard finalIndex >= 0 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 }
|
|
|
|
// Get the fullscreen mode we want to toggle
|
|
let fullscreenMode: FullscreenMode
|
|
if let any = notification.userInfo?[Ghostty.Notification.FullscreenModeKey],
|
|
let mode = any as? FullscreenMode {
|
|
fullscreenMode = mode
|
|
} else {
|
|
Ghostty.logger.warning("no fullscreen mode specified or invalid mode, doing nothing")
|
|
return
|
|
}
|
|
|
|
toggleFullscreen(mode: fullscreenMode)
|
|
}
|
|
|
|
private struct DerivedConfig {
|
|
let macosTitlebarStyle: String
|
|
|
|
init() {
|
|
self.macosTitlebarStyle = "system"
|
|
}
|
|
|
|
init(_ config: Ghostty.Config) {
|
|
self.macosTitlebarStyle = config.macosTitlebarStyle
|
|
}
|
|
}
|
|
}
|