mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-07-15 16:26:08 +03:00
317 lines
12 KiB
Swift
317 lines
12 KiB
Swift
import AppKit
|
|
import SwiftUI
|
|
import GhosttyKit
|
|
|
|
/// The base class for all standalone, "normal" terminal windows. This sets the basic
|
|
/// style and configuration of the window based on the app configuration.
|
|
class TerminalWindow: NSWindow {
|
|
/// This is the key in UserDefaults to use for the default `level` value. This is
|
|
/// used by the manual float on top menu item feature.
|
|
static let defaultLevelKey: String = "TerminalDefaultLevel"
|
|
|
|
/// The configuration derived from the Ghostty config so we don't need to rely on references.
|
|
private var derivedConfig: DerivedConfig?
|
|
|
|
/// Gets the terminal controller from the window controller.
|
|
var terminalController: TerminalController? {
|
|
windowController as? TerminalController
|
|
}
|
|
|
|
// MARK: NSWindow Overrides
|
|
|
|
override func awakeFromNib() {
|
|
guard let appDelegate = NSApp.delegate as? AppDelegate else { return }
|
|
|
|
// All new windows are based on the app config at the time of creation.
|
|
let config = appDelegate.ghostty.config
|
|
|
|
// Setup our initial config
|
|
derivedConfig = .init(config)
|
|
|
|
// If window decorations are disabled, remove our title
|
|
if (!config.windowDecorations) { styleMask.remove(.titled) }
|
|
|
|
// Set our window positioning to coordinates if config value exists, otherwise
|
|
// fallback to original centering behavior
|
|
setInitialWindowPosition(
|
|
x: config.windowPositionX,
|
|
y: config.windowPositionY,
|
|
windowDecorations: config.windowDecorations)
|
|
|
|
// If our traffic buttons should be hidden, then hide them
|
|
if config.macosWindowButtons == .hidden {
|
|
hideWindowButtons()
|
|
}
|
|
|
|
// Setup the accessory view for tabs that shows our keyboard shortcuts,
|
|
// zoomed state, etc. Note I tried to use SwiftUI here but ran into issues
|
|
// where buttons were not clickable.
|
|
let stackView = NSStackView(views: [keyEquivalentLabel, resetZoomTabButton])
|
|
stackView.setHuggingPriority(.defaultHigh, for: .horizontal)
|
|
stackView.spacing = 3
|
|
tab.accessoryView = stackView
|
|
|
|
// Get our saved level
|
|
level = UserDefaults.standard.value(forKey: Self.defaultLevelKey) as? NSWindow.Level ?? .normal
|
|
}
|
|
|
|
// Both of these must be true for windows without decorations to be able to
|
|
// still become key/main and receive events.
|
|
override var canBecomeKey: Bool { return true }
|
|
override var canBecomeMain: Bool { return true }
|
|
|
|
override func becomeKey() {
|
|
super.becomeKey()
|
|
resetZoomTabButton.contentTintColor = .controlAccentColor
|
|
}
|
|
|
|
override func resignKey() {
|
|
super.resignKey()
|
|
resetZoomTabButton.contentTintColor = .secondaryLabelColor
|
|
}
|
|
|
|
override func mergeAllWindows(_ sender: Any?) {
|
|
super.mergeAllWindows(sender)
|
|
|
|
// It takes an event loop cycle to merge all the windows so we set a
|
|
// short timer to relabel the tabs (issue #1902)
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
|
|
self?.terminalController?.relabelTabs()
|
|
}
|
|
}
|
|
|
|
// MARK: Tab Key Equivalents
|
|
|
|
// TODO: rename once Legacy window removes
|
|
var keyEquivalent2: String? = nil {
|
|
didSet {
|
|
// When our key equivalent is set, we must update the tab label.
|
|
guard let keyEquivalent2 else {
|
|
keyEquivalentLabel.attributedStringValue = NSAttributedString()
|
|
return
|
|
}
|
|
|
|
keyEquivalentLabel.attributedStringValue = NSAttributedString(
|
|
string: "\(keyEquivalent2) ",
|
|
attributes: [
|
|
.font: NSFont.systemFont(ofSize: NSFont.smallSystemFontSize),
|
|
.foregroundColor: isKeyWindow ? NSColor.labelColor : NSColor.secondaryLabelColor,
|
|
])
|
|
}
|
|
}
|
|
|
|
/// The label that has the key equivalent for tab views.
|
|
private lazy var keyEquivalentLabel: NSTextField = {
|
|
let label = NSTextField(labelWithAttributedString: NSAttributedString())
|
|
label.setContentCompressionResistancePriority(.windowSizeStayPut, for: .horizontal)
|
|
label.postsFrameChangedNotifications = true
|
|
return label
|
|
}()
|
|
|
|
// MARK: Surface Zoom
|
|
|
|
/// Set to true if a surface is currently zoomed to show the reset zoom button.
|
|
var surfaceIsZoomed2: Bool = false {
|
|
didSet {
|
|
// Show/hide our reset zoom button depending on if we're zoomed.
|
|
// We want to show it if we are zoomed.
|
|
resetZoomTabButton.isHidden = !surfaceIsZoomed2
|
|
}
|
|
}
|
|
|
|
private lazy var resetZoomTabButton: NSButton = generateResetZoomButton()
|
|
|
|
private func generateResetZoomButton() -> NSButton {
|
|
let button = NSButton()
|
|
button.isHidden = true
|
|
button.target = terminalController
|
|
button.action = #selector(TerminalController.splitZoom(_:))
|
|
button.isBordered = false
|
|
button.allowsExpansionToolTips = true
|
|
button.toolTip = "Reset Zoom"
|
|
button.contentTintColor = .controlAccentColor
|
|
button.state = .on
|
|
button.image = NSImage(named:"ResetZoom")
|
|
button.frame = NSRect(x: 0, y: 0, width: 20, height: 20)
|
|
button.translatesAutoresizingMaskIntoConstraints = false
|
|
button.widthAnchor.constraint(equalToConstant: 20).isActive = true
|
|
button.heightAnchor.constraint(equalToConstant: 20).isActive = true
|
|
return button
|
|
}
|
|
|
|
// MARK: Title Text
|
|
|
|
override var title: String {
|
|
didSet {
|
|
// Whenever we change the window title we must also update our
|
|
// tab title if we're using custom fonts.
|
|
tab.attributedTitle = attributedTitle
|
|
}
|
|
}
|
|
|
|
// Used to set the titlebar font.
|
|
var titlebarFont2: NSFont? {
|
|
didSet {
|
|
let font = titlebarFont2 ?? NSFont.titleBarFont(ofSize: NSFont.systemFontSize)
|
|
|
|
titlebarTextField?.font = font
|
|
tab.attributedTitle = attributedTitle
|
|
}
|
|
}
|
|
|
|
// Find the NSTextField responsible for displaying the titlebar's title.
|
|
private var titlebarTextField: NSTextField? {
|
|
titlebarContainer?
|
|
.firstDescendant(withClassName: "NSTitlebarView")?
|
|
.firstDescendant(withClassName: "NSTextField") as? NSTextField
|
|
}
|
|
|
|
// Return a styled representation of our title property.
|
|
private var attributedTitle: NSAttributedString? {
|
|
guard let titlebarFont = titlebarFont2 else { return nil }
|
|
|
|
let attributes: [NSAttributedString.Key: Any] = [
|
|
.font: titlebarFont,
|
|
.foregroundColor: isKeyWindow ? NSColor.labelColor : NSColor.secondaryLabelColor,
|
|
]
|
|
return NSAttributedString(string: title, attributes: attributes)
|
|
}
|
|
|
|
var titlebarContainer: NSView? {
|
|
// If we aren't fullscreen then the titlebar container is part of our window.
|
|
if !styleMask.contains(.fullScreen) {
|
|
return contentView?.firstViewFromRoot(withClassName: "NSTitlebarContainerView")
|
|
}
|
|
|
|
// If we are fullscreen, the titlebar container view is part of a separate
|
|
// "fullscreen window", we need to find the window and then get the view.
|
|
for window in NSApplication.shared.windows {
|
|
// This is the private window class that contains the toolbar
|
|
guard window.className == "NSToolbarFullScreenWindow" else { continue }
|
|
|
|
// The parent will match our window. This is used to filter the correct
|
|
// fullscreen window if we have multiple.
|
|
guard window.parent == self else { continue }
|
|
|
|
return window.contentView?.firstViewFromRoot(withClassName: "NSTitlebarContainerView")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// MARK: Positioning And Styling
|
|
|
|
/// This is called by the controller when there is a need to reset the window apperance.
|
|
func syncAppearance(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) {
|
|
// If our window is not visible, then we do nothing. Some things such as blurring
|
|
// have no effect if the window is not visible. Ultimately, we'll have this called
|
|
// at some point when a surface becomes focused.
|
|
guard isVisible else { return }
|
|
|
|
// Basic properties
|
|
appearance = surfaceConfig.windowAppearance
|
|
hasShadow = surfaceConfig.macosWindowShadow
|
|
|
|
// Window transparency only takes effect if our window is not native fullscreen.
|
|
// In native fullscreen we disable transparency/opacity because the background
|
|
// becomes gray and widgets show through.
|
|
if !styleMask.contains(.fullScreen) &&
|
|
surfaceConfig.backgroundOpacity < 1
|
|
{
|
|
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.
|
|
backgroundColor = .white.withAlphaComponent(0.001)
|
|
|
|
if let appDelegate = NSApp.delegate as? AppDelegate {
|
|
ghostty_set_window_background_blur(
|
|
appDelegate.ghostty.app,
|
|
Unmanaged.passUnretained(self).toOpaque())
|
|
}
|
|
} else {
|
|
isOpaque = true
|
|
|
|
let backgroundColor = preferredBackgroundColor ?? NSColor(surfaceConfig.backgroundColor)
|
|
self.backgroundColor = backgroundColor.withAlphaComponent(1)
|
|
}
|
|
}
|
|
|
|
/// The preferred window background color. The current window background color may not be set
|
|
/// to this, since this is dynamic based on the state of the surface tree.
|
|
///
|
|
/// This background color will include alpha transparency if set. If the caller doesn't want that,
|
|
/// change the alpha channel again manually.
|
|
var preferredBackgroundColor: NSColor? {
|
|
if let terminalController, !terminalController.surfaceTree.isEmpty {
|
|
// If our focused surface borders the top then we prefer its background color
|
|
if let focusedSurface = terminalController.focusedSurface,
|
|
let treeRoot = terminalController.surfaceTree.root,
|
|
let focusedNode = treeRoot.node(view: focusedSurface),
|
|
treeRoot.spatial().doesBorder(side: .up, from: focusedNode),
|
|
let backgroundcolor = focusedSurface.backgroundColor {
|
|
let alpha = focusedSurface.derivedConfig.backgroundOpacity.clamped(to: 0.001...1)
|
|
return NSColor(backgroundcolor).withAlphaComponent(alpha)
|
|
}
|
|
|
|
// Doesn't border the top or we don't have a focused surface, so
|
|
// we try to match the top-left surface.
|
|
let topLeftSurface = terminalController.surfaceTree.root?.leftmostLeaf()
|
|
if let topLeftBgColor = topLeftSurface?.backgroundColor {
|
|
let alpha = topLeftSurface?.derivedConfig.backgroundOpacity.clamped(to: 0.001...1) ?? 1
|
|
return NSColor(topLeftBgColor).withAlphaComponent(alpha)
|
|
}
|
|
}
|
|
|
|
let alpha = derivedConfig?.backgroundOpacity.clamped(to: 0.001...1) ?? 1
|
|
return derivedConfig?.backgroundColor.withAlphaComponent(alpha)
|
|
}
|
|
|
|
private func setInitialWindowPosition(x: Int16?, y: Int16?, windowDecorations: Bool) {
|
|
// If we don't have an X/Y then we try to use the previously saved window pos.
|
|
guard let x, let y else {
|
|
if (!LastWindowPosition.shared.restore(self)) {
|
|
center()
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// Prefer the screen our window is being placed on otherwise our primary screen.
|
|
guard let screen = screen ?? NSScreen.screens.first else {
|
|
center()
|
|
return
|
|
}
|
|
|
|
// Orient based on the top left of the primary monitor
|
|
let frame = screen.visibleFrame
|
|
setFrameOrigin(.init(
|
|
x: frame.minX + CGFloat(x),
|
|
y: frame.maxY - (CGFloat(y) + frame.height)))
|
|
}
|
|
|
|
private func hideWindowButtons() {
|
|
standardWindowButton(.closeButton)?.isHidden = true
|
|
standardWindowButton(.miniaturizeButton)?.isHidden = true
|
|
standardWindowButton(.zoomButton)?.isHidden = true
|
|
}
|
|
|
|
// MARK: Config
|
|
|
|
struct DerivedConfig {
|
|
let backgroundColor: NSColor
|
|
let backgroundOpacity: Double
|
|
|
|
init() {
|
|
self.backgroundColor = NSColor.windowBackgroundColor
|
|
self.backgroundOpacity = 1
|
|
}
|
|
|
|
init(_ config: Ghostty.Config) {
|
|
self.backgroundColor = NSColor(config.backgroundColor)
|
|
self.backgroundOpacity = config.backgroundOpacity
|
|
}
|
|
}
|
|
}
|