diff --git a/.github/scripts/request_review.py b/.github/scripts/request_review.py
index 1a53e82e4..d799e7c58 100644
--- a/.github/scripts/request_review.py
+++ b/.github/scripts/request_review.py
@@ -2,113 +2,187 @@
# requires-python = ">=3.9"
# dependencies = [
# "githubkit",
+# "loguru",
# ]
# ///
+from __future__ import annotations
+
import asyncio
import os
import re
+import sys
+from collections.abc import Iterator
+from contextlib import contextmanager
from itertools import chain
from githubkit import GitHub
+from githubkit.exception import RequestFailed
+from loguru import logger
ORG_NAME = "ghostty-org"
REPO_NAME = "ghostty"
ALLOWED_PARENT_TEAM = "localization"
LOCALIZATION_TEAM_NAME_PATTERN = re.compile(r"[a-z]{2}_[A-Z]{2}")
+LEVEL_MAP = {"DEBUG": "DBG", "WARNING": "WRN", "ERROR": "ERR"}
+
+logger.remove()
+logger.add(
+ sys.stderr,
+ format=lambda record: (
+ "{time:YYYY-MM-DD HH:mm:ss.SSS} | "
+ f"{LEVEL_MAP[record['level'].name]} | "
+ "{function}:{line} - "
+ "{message}\n"
+ ),
+ backtrace=True,
+ diagnose=True,
+)
+
+
+@contextmanager
+def log_fail(message: str, *, die: bool = True) -> Iterator[None]:
+ try:
+ yield
+ except RequestFailed as exc:
+ logger.error(message)
+ logger.error(exc)
+ logger.error(exc.response.raw_response.json())
+ if die:
+ sys.exit(1)
+
gh = GitHub(os.environ["GITHUB_TOKEN"])
+with log_fail("Invalid token"):
+ # Do the simplest request as a test
+ gh.rest.rate_limit.get()
+
async def fetch_and_parse_codeowners() -> dict[str, str]:
- content = (
- await gh.rest.repos.async_get_content(
- ORG_NAME,
- REPO_NAME,
- "CODEOWNERS",
- headers={"Accept": "application/vnd.github.raw+json"},
- )
- ).text
+ logger.debug("Fetching CODEOWNERS file...")
+ with log_fail("Failed to fetch CODEOWNERS file"):
+ content = (
+ await gh.rest.repos.async_get_content(
+ ORG_NAME,
+ REPO_NAME,
+ "CODEOWNERS",
+ headers={"Accept": "application/vnd.github.raw+json"},
+ )
+ ).text
+ logger.debug("Parsing CODEOWNERS file...")
codeowners: dict[str, str] = {}
for line in content.splitlines():
if not line or line.lstrip().startswith("#"):
continue
+
# This assumes that all entries only list one owner
# and that this owner is a team (ghostty-org/foobar)
path, owner = line.split()
- codeowners[path.lstrip("/")] = owner.removeprefix(f"@{ORG_NAME}/")
+ path = path.lstrip("/")
+ owner = owner.removeprefix(f"@{ORG_NAME}/")
+
+ if not is_localization_team(owner):
+ logger.debug(f"Skipping non-l11n codeowner {owner!r} for {path}")
+ continue
+
+ codeowners[path] = owner
+ logger.debug(f"Found codeowner {owner!r} for {path}")
return codeowners
async def get_team_members(team_name: str) -> list[str]:
- team = (await gh.rest.teams.async_get_by_name(ORG_NAME, team_name)).parsed_data
+ logger.debug(f"Fetching team {team_name!r}...")
+ with log_fail(f"Failed to fetch team {team_name!r}"):
+ team = (await gh.rest.teams.async_get_by_name(ORG_NAME, team_name)).parsed_data
+
if team.parent and team.parent.slug == ALLOWED_PARENT_TEAM:
- members = (
- await gh.rest.teams.async_list_members_in_org(ORG_NAME, team_name)
- ).parsed_data
- return [m.login for m in members]
+ logger.debug(f"Fetching team {team_name!r} members...")
+ with log_fail(f"Failed to fetch team {team_name!r} members"):
+ resp = await gh.rest.teams.async_list_members_in_org(ORG_NAME, team_name)
+ members = [m.login for m in resp.parsed_data]
+ logger.debug(f"Team {team_name!r} members: {', '.join(members)}")
+ return members
+
+ logger.warning(f"Team {team_name} does not have a {ALLOWED_PARENT_TEAM!r} parent")
return []
async def get_changed_files(pr_number: int) -> list[str]:
- diff_entries = (
- await gh.rest.pulls.async_list_files(
- ORG_NAME,
- REPO_NAME,
- pr_number,
- per_page=3000,
- headers={"Accept": "application/vnd.github+json"},
- )
- ).parsed_data
- return [d.filename for d in diff_entries]
-
-
-async def request_review(pr_number: int, pr_author: str, *users: str) -> None:
- await asyncio.gather(
- *(
- gh.rest.pulls.async_request_reviewers(
+ logger.debug("Gathering changed files...")
+ with log_fail("Failed to gather changed files"):
+ diff_entries = (
+ await gh.rest.pulls.async_list_files(
ORG_NAME,
REPO_NAME,
pr_number,
+ per_page=3000,
headers={"Accept": "application/vnd.github+json"},
- data={"reviewers": [user]},
)
- for user in users
- if user != pr_author
+ ).parsed_data
+ return [d.filename for d in diff_entries]
+
+
+async def request_review(pr_number: int, user: str, pr_author: str) -> None:
+ if user == pr_author:
+ logger.debug(f"Skipping review request for {user!r} (is PR author)")
+ logger.debug(f"Requesting review from {user!r}...")
+ with log_fail(f"Failed to request review from {user}", die=False):
+ await gh.rest.pulls.async_request_reviewers(
+ ORG_NAME,
+ REPO_NAME,
+ pr_number,
+ headers={"Accept": "application/vnd.github+json"},
+ data={"reviewers": [user]},
)
- )
def is_localization_team(team_name: str) -> bool:
return LOCALIZATION_TEAM_NAME_PATTERN.fullmatch(team_name) is not None
+async def get_pr_author(pr_number: int) -> str:
+ logger.debug("Fetching PR author...")
+ with log_fail("Failed to fetch PR author"):
+ resp = await gh.rest.pulls.async_get(ORG_NAME, REPO_NAME, pr_number)
+ pr_author = resp.parsed_data.user.login
+ logger.debug(f"Found author: {pr_author!r}")
+ return pr_author
+
+
async def main() -> None:
+ logger.debug("Reading PR number...")
pr_number = int(os.environ["PR_NUMBER"])
+ logger.debug(f"Starting review request process for PR #{pr_number}...")
+
changed_files = await get_changed_files(pr_number)
- pr_author = (
- await gh.rest.pulls.async_get(ORG_NAME, REPO_NAME, pr_number)
- ).parsed_data.user.login
- localization_codewners = {
- path: owner
- for path, owner in (await fetch_and_parse_codeowners()).items()
- if is_localization_team(owner)
- }
+ logger.debug(f"Changed files: {', '.join(map(repr, changed_files))}")
+
+ pr_author = await get_pr_author(pr_number)
+ codeowners = await fetch_and_parse_codeowners()
found_owners = set[str]()
for file in changed_files:
- for path, owner in localization_codewners.items():
+ logger.debug(f"Finding owner for {file!r}...")
+ for path, owner in codeowners.items():
if file.startswith(path):
+ logger.debug(f"Found owner: {owner!r}")
break
else:
+ logger.debug("No owner found")
continue
found_owners.add(owner)
member_lists = await asyncio.gather(
*(get_team_members(owner) for owner in found_owners)
)
- await request_review(pr_number, pr_author, *chain.from_iterable(member_lists))
+ await asyncio.gather(
+ *(
+ request_review(pr_number, user, pr_author)
+ for user in chain.from_iterable(member_lists)
+ )
+ )
if __name__ == "__main__":
diff --git a/include/ghostty.h b/include/ghostty.h
index 2dc1bffef..f30275b2c 100644
--- a/include/ghostty.h
+++ b/include/ghostty.h
@@ -601,6 +601,7 @@ typedef enum {
GHOSTTY_ACTION_RELOAD_CONFIG,
GHOSTTY_ACTION_CONFIG_CHANGE,
GHOSTTY_ACTION_CLOSE_WINDOW,
+ GHOSTTY_ACTION_RING_BELL,
} ghostty_action_tag_e;
typedef union {
diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj
index b4c00946c..b69541504 100644
--- a/macos/Ghostty.xcodeproj/project.pbxproj
+++ b/macos/Ghostty.xcodeproj/project.pbxproj
@@ -55,6 +55,8 @@
A571AB1D2A206FCF00248498 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5D495A1299BEC7E00DD1313 /* GhosttyKit.xcframework */; };
A57D79272C9C879B001D522E /* SecureInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = A57D79262C9C8798001D522E /* SecureInput.swift */; };
A586167C2B7703CC009BDB1D /* fish in Resources */ = {isa = PBXBuildFile; fileRef = A586167B2B7703CC009BDB1D /* fish */; };
+ A5874D992DAD751B00E83852 /* CGS.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5874D982DAD751A00E83852 /* CGS.swift */; };
+ A5874D9D2DAD786100E83852 /* NSWindow+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */; };
A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59444F629A2ED5200725BBA /* SettingsView.swift */; };
A59630972AEE163600D64628 /* HostingWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59630962AEE163600D64628 /* HostingWindow.swift */; };
A596309A2AEE1C6400D64628 /* Terminal.xib in Resources */ = {isa = PBXBuildFile; fileRef = A59630992AEE1C6400D64628 /* Terminal.xib */; };
@@ -154,6 +156,8 @@
A571AB1C2A206FC600248498 /* Ghostty-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "Ghostty-Info.plist"; sourceTree = ""; };
A57D79262C9C8798001D522E /* SecureInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureInput.swift; sourceTree = ""; };
A586167B2B7703CC009BDB1D /* fish */ = {isa = PBXFileReference; lastKnownFileType = folder; name = fish; path = "../zig-out/share/fish"; sourceTree = ""; };
+ A5874D982DAD751A00E83852 /* CGS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGS.swift; sourceTree = ""; };
+ A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSWindow+Extension.swift"; sourceTree = ""; };
A59444F629A2ED5200725BBA /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; };
A59630962AEE163600D64628 /* HostingWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostingWindow.swift; sourceTree = ""; };
A59630992AEE1C6400D64628 /* Terminal.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = Terminal.xib; sourceTree = ""; };
@@ -274,13 +278,13 @@
A534263D2A7DCBB000EBB7A2 /* Helpers */ = {
isa = PBXGroup;
children = (
+ A5874D9B2DAD781100E83852 /* Private */,
A5AEB1642D5BE7BF00513529 /* LastWindowPosition.swift */,
A5A6F7292CC41B8700B232A5 /* Xcode.swift */,
A5CEAFFE29C2410700646FDA /* Backport.swift */,
A5333E1B2B5A1CE3008AEFF7 /* CrossKit.swift */,
A5CBD0572C9F30860017A1AE /* Cursor.swift */,
A5D0AF3C2B37804400D21823 /* CodableBridge.swift */,
- A5A2A3C92D4445E20033CF96 /* Dock.swift */,
A52FFF582CAA4FF1000C6A5B /* Fullscreen.swift */,
A59630962AEE163600D64628 /* HostingWindow.swift */,
A5CA378B2D2A4DE800931030 /* KeyboardLayout.swift */,
@@ -293,6 +297,7 @@
A52FFF5C2CAB4D05000C6A5B /* NSScreen+Extension.swift */,
C1F26EA62B738B9900404083 /* NSView+Extension.swift */,
AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */,
+ A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */,
A5985CD62C320C4500C57AD3 /* String+Extension.swift */,
A5CC36142C9CDA03004D6760 /* View+Extension.swift */,
A5CA378D2D31D6C100931030 /* Weak.swift */,
@@ -403,6 +408,15 @@
path = "Secure Input";
sourceTree = "";
};
+ A5874D9B2DAD781100E83852 /* Private */ = {
+ isa = PBXGroup;
+ children = (
+ A5874D982DAD751A00E83852 /* CGS.swift */,
+ A5A2A3C92D4445E20033CF96 /* Dock.swift */,
+ );
+ path = Private;
+ sourceTree = "";
+ };
A59630982AEE1C4400D64628 /* Terminal */ = {
isa = PBXGroup;
children = (
@@ -634,6 +648,7 @@
A59630A42AF059BB00D64628 /* Ghostty.SplitNode.swift in Sources */,
A514C8D62B54A16400493A16 /* Ghostty.Config.swift in Sources */,
A54B0CEB2D0CFB4C00CBEFF8 /* NSImage+Extension.swift in Sources */,
+ A5874D9D2DAD786100E83852 /* NSWindow+Extension.swift in Sources */,
A54D786C2CA7978E001B19B1 /* BaseTerminalController.swift in Sources */,
A59FB5CF2AE0DB50009128F3 /* InspectorView.swift in Sources */,
CFBB5FEA2D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift in Sources */,
@@ -669,6 +684,7 @@
A5CDF1952AAFA19600513312 /* ConfigurationErrorsView.swift in Sources */,
A55B7BBC29B6FC330055DE60 /* SurfaceView.swift in Sources */,
A5333E1C2B5A1CE3008AEFF7 /* CrossKit.swift in Sources */,
+ A5874D992DAD751B00E83852 /* CGS.swift in Sources */,
A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */,
A56D58862ACDDB4100508D2C /* Ghostty.Shell.swift in Sources */,
A5985CD72C320C4500C57AD3 /* String+Extension.swift in Sources */,
diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift
index fac3a2fbb..6e5607c6f 100644
--- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift
+++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift
@@ -3,12 +3,6 @@ import Cocoa
import SwiftUI
import GhosttyKit
-// This is a Apple's private function that we need to call to get the active space.
-@_silgen_name("CGSGetActiveSpace")
-func CGSGetActiveSpace(_ cid: Int) -> size_t
-@_silgen_name("CGSMainConnectionID")
-func CGSMainConnectionID() -> Int
-
/// Controller for the "quick" terminal.
class QuickTerminalController: BaseTerminalController {
override var windowNibName: NSNib.Name? { "QuickTerminal" }
@@ -25,7 +19,7 @@ class QuickTerminalController: BaseTerminalController {
private var previousApp: NSRunningApplication? = nil
// The active space when the quick terminal was last shown.
- private var previousActiveSpace: size_t = 0
+ private var previousActiveSpace: CGSSpace? = nil
/// Non-nil if we have hidden dock state.
private var hiddenDock: HiddenDock? = nil
@@ -51,7 +45,7 @@ class QuickTerminalController: BaseTerminalController {
object: nil)
center.addObserver(
self,
- selector: #selector(onToggleFullscreen),
+ selector: #selector(onToggleFullscreen(notification:)),
name: Ghostty.Notification.ghosttyToggleFullscreen,
object: nil)
center.addObserver(
@@ -154,14 +148,24 @@ class QuickTerminalController: BaseTerminalController {
animateOut()
case .move:
- let currentActiveSpace = CGSGetActiveSpace(CGSMainConnectionID())
+ let currentActiveSpace = CGSSpace.active()
if previousActiveSpace == currentActiveSpace {
// We haven't moved spaces. We lost focus to another app on the
// current space. Animate out.
animateOut()
} else {
- // We've moved to a different space. Bring the quick terminal back
- // into view.
+ // We've moved to a different space.
+
+ // If we're fullscreen, we need to exit fullscreen because the visible
+ // bounds may have changed causing a new behavior.
+ if let fullscreenStyle, fullscreenStyle.isFullscreen {
+ fullscreenStyle.exit()
+ DispatchQueue.main.async {
+ self.onToggleFullscreen()
+ }
+ }
+
+ // Make the window visible again on this space
DispatchQueue.main.async {
self.window?.makeKeyAndOrderFront(nil)
}
@@ -224,7 +228,7 @@ class QuickTerminalController: BaseTerminalController {
}
// Set previous active space
- self.previousActiveSpace = CGSGetActiveSpace(CGSMainConnectionID())
+ self.previousActiveSpace = CGSSpace.active()
// Animate the window in
animateWindowIn(window: window, from: position)
@@ -485,9 +489,23 @@ class QuickTerminalController: BaseTerminalController {
@objc private func onToggleFullscreen(notification: SwiftUI.Notification) {
guard let target = notification.object as? Ghostty.SurfaceView else { return }
guard target == self.focusedSurface else { return }
+ onToggleFullscreen()
+ }
- // We ignore the requested mode and always use non-native for the quick terminal
- toggleFullscreen(mode: .nonNative)
+ private func onToggleFullscreen() {
+ // We ignore the configured fullscreen style and always use non-native
+ // because the way the quick terminal works doesn't support native.
+ //
+ // An additional detail is that if the is NOT frontmost, then our
+ // NSApp.presentationOptions will not take effect so we must always
+ // do the visible menu mode since we can't get rid of the menu.
+ let mode: FullscreenMode = if (NSApp.isFrontmost) {
+ .nonNative
+ } else {
+ .nonNativeVisibleMenu
+ }
+
+ toggleFullscreen(mode: mode)
}
@objc private func ghosttyConfigDidChange(_ notification: Notification) {
diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift
index c6a3d7629..230d3a9e2 100644
--- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift
+++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift
@@ -743,6 +743,13 @@ extension Ghostty {
override func mouseExited(with event: NSEvent) {
guard let surface = self.surface else { return }
+ // If the mouse is being dragged then we don't have to emit
+ // this because we get mouse drag events even if we've already
+ // exited the viewport (i.e. mouseDragged)
+ if NSEvent.pressedMouseButtons != 0 {
+ return
+ }
+
// Negative values indicate cursor has left the viewport
let mods = Ghostty.ghosttyMods(event.modifierFlags)
ghostty_surface_mouse_pos(surface, -1, -1, mods)
diff --git a/macos/Sources/Helpers/Fullscreen.swift b/macos/Sources/Helpers/Fullscreen.swift
index 59865fc9e..b6fb08271 100644
--- a/macos/Sources/Helpers/Fullscreen.swift
+++ b/macos/Sources/Helpers/Fullscreen.swift
@@ -180,7 +180,7 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle {
}
// Hide the menu if requested
- if (properties.hideMenu) {
+ if (properties.hideMenu && savedState.menu) {
hideMenu()
}
@@ -224,7 +224,9 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle {
if savedState.dock {
unhideDock()
}
- unhideMenu()
+ if (properties.hideMenu && savedState.menu) {
+ unhideMenu()
+ }
// Restore our saved state
window.styleMask = savedState.styleMask
@@ -273,7 +275,8 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle {
// calculate this ourselves.
var frame = screen.frame
- if (!properties.hideMenu) {
+ if (!NSApp.presentationOptions.contains(.autoHideMenuBar) &&
+ !NSApp.presentationOptions.contains(.hideMenuBar)) {
// We need to subtract the menu height since we're still showing it.
frame.size.height -= NSApp.mainMenu?.menuBarHeight ?? 0
@@ -340,6 +343,7 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle {
let contentFrame: NSRect
let styleMask: NSWindow.StyleMask
let dock: Bool
+ let menu: Bool
init?(_ window: NSWindow) {
guard let contentView = window.contentView else { return nil }
@@ -350,6 +354,18 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle {
self.contentFrame = window.convertToScreen(contentView.frame)
self.styleMask = window.styleMask
self.dock = window.screen?.hasDock ?? false
+
+ // We hide the menu only if this window is not on any fullscreen
+ // spaces. We do this because fullscreen spaces already hide the
+ // menu and if we insert/remove this presentation option we get
+ // issues (see #7075)
+ let activeSpace = CGSSpace.active()
+ let spaces = CGSSpace.list(for: window.cgWindowId)
+ if spaces.contains(activeSpace) {
+ self.menu = activeSpace.type != .fullscreen
+ } else {
+ self.menu = spaces.allSatisfy { $0.type != .fullscreen }
+ }
}
}
}
diff --git a/macos/Sources/Helpers/NSApplication+Extension.swift b/macos/Sources/Helpers/NSApplication+Extension.swift
index 0580cd5fc..d8e41523a 100644
--- a/macos/Sources/Helpers/NSApplication+Extension.swift
+++ b/macos/Sources/Helpers/NSApplication+Extension.swift
@@ -1,5 +1,7 @@
import Cocoa
+// MARK: Presentation Options
+
extension NSApplication {
private static var presentationOptionCounts: [NSApplication.PresentationOptions.Element: UInt] = [:]
@@ -29,3 +31,13 @@ extension NSApplication.PresentationOptions.Element: @retroactive Hashable {
hasher.combine(rawValue)
}
}
+
+// MARK: Frontmost
+
+extension NSApplication {
+ /// True if the application is frontmost. This isn't exactly the same as isActive because
+ /// an app can be active but not be frontmost if the window with activity is an NSPanel.
+ var isFrontmost: Bool {
+ NSWorkspace.shared.frontmostApplication?.bundleIdentifier == Bundle.main.bundleIdentifier
+ }
+}
diff --git a/macos/Sources/Helpers/NSWindow+Extension.swift b/macos/Sources/Helpers/NSWindow+Extension.swift
new file mode 100644
index 000000000..c7523bdb7
--- /dev/null
+++ b/macos/Sources/Helpers/NSWindow+Extension.swift
@@ -0,0 +1,8 @@
+import AppKit
+
+extension NSWindow {
+ /// Get the CGWindowID type for the window (used for low level CoreGraphics APIs).
+ var cgWindowId: CGWindowID {
+ CGWindowID(windowNumber)
+ }
+}
diff --git a/macos/Sources/Helpers/Private/CGS.swift b/macos/Sources/Helpers/Private/CGS.swift
new file mode 100644
index 000000000..0d3b9aa4c
--- /dev/null
+++ b/macos/Sources/Helpers/Private/CGS.swift
@@ -0,0 +1,81 @@
+import AppKit
+
+// MARK: - CGS Private API Declarations
+
+typealias CGSConnectionID = Int32
+typealias CGSSpaceID = size_t
+
+@_silgen_name("CGSMainConnectionID")
+private func CGSMainConnectionID() -> CGSConnectionID
+
+@_silgen_name("CGSGetActiveSpace")
+private func CGSGetActiveSpace(_ cid: CGSConnectionID) -> CGSSpaceID
+
+@_silgen_name("CGSSpaceGetType")
+private func CGSSpaceGetType(_ cid: CGSConnectionID, _ spaceID: CGSSpaceID) -> CGSSpaceType
+
+@_silgen_name("CGSCopySpacesForWindows")
+func CGSCopySpacesForWindows(
+ _ cid: CGSConnectionID,
+ _ mask: CGSSpaceMask,
+ _ windowIDs: CFArray
+) -> Unmanaged?
+
+// MARK: - CGS Space
+
+/// https://github.com/NUIKit/CGSInternal/blob/c4f6f559d624dc1cfc2bf24c8c19dbf653317fcf/CGSSpace.h#L40
+/// converted to Swift
+struct CGSSpaceMask: OptionSet {
+ let rawValue: UInt32
+
+ static let includesCurrent = CGSSpaceMask(rawValue: 1 << 0)
+ static let includesOthers = CGSSpaceMask(rawValue: 1 << 1)
+ static let includesUser = CGSSpaceMask(rawValue: 1 << 2)
+
+ static let includesVisible = CGSSpaceMask(rawValue: 1 << 16)
+
+ static let currentSpace: CGSSpaceMask = [.includesUser, .includesCurrent]
+ static let otherSpaces: CGSSpaceMask = [.includesOthers, .includesCurrent]
+ static let allSpaces: CGSSpaceMask = [.includesUser, .includesOthers, .includesCurrent]
+ static let allVisibleSpaces: CGSSpaceMask = [.includesVisible, .allSpaces]
+}
+
+/// Represents a unique identifier for a macOS Space (Desktop, Fullscreen, etc).
+struct CGSSpace: Hashable, CustomStringConvertible {
+ let rawValue: CGSSpaceID
+
+ var description: String {
+ "SpaceID(\(rawValue))"
+ }
+
+ /// Returns the currently active space.
+ static func active() -> CGSSpace {
+ let space = CGSGetActiveSpace(CGSMainConnectionID())
+ return .init(rawValue: space)
+ }
+
+ /// List the spaces for the given window.
+ static func list(for windowID: CGWindowID, mask: CGSSpaceMask = .allSpaces) -> [CGSSpace] {
+ guard let spaces = CGSCopySpacesForWindows(
+ CGSMainConnectionID(),
+ mask,
+ [windowID] as CFArray
+ ) else { return [] }
+ guard let spaceIDs = spaces.takeRetainedValue() as? [CGSSpaceID] else { return [] }
+ return spaceIDs.map(CGSSpace.init)
+ }
+}
+
+// MARK: - CGS Space Types
+
+enum CGSSpaceType: UInt32 {
+ case user = 0
+ case system = 2
+ case fullscreen = 4
+}
+
+extension CGSSpace {
+ var type: CGSSpaceType {
+ CGSSpaceGetType(CGSMainConnectionID(), rawValue)
+ }
+}
diff --git a/macos/Sources/Helpers/Dock.swift b/macos/Sources/Helpers/Private/Dock.swift
similarity index 100%
rename from macos/Sources/Helpers/Dock.swift
rename to macos/Sources/Helpers/Private/Dock.swift
diff --git a/po/nb_NO.UTF-8.po b/po/nb_NO.UTF-8.po
index ab6252f85..bd7c8876a 100644
--- a/po/nb_NO.UTF-8.po
+++ b/po/nb_NO.UTF-8.po
@@ -4,13 +4,14 @@
# Hanna Rose , 2025.
# Uzair Aftab , 2025.
# Christoffer Tønnessen , 2025.
+# cryptocode , 2025.
#
msgid ""
msgstr ""
"Project-Id-Version: com.mitchellh.ghostty\n"
"Report-Msgid-Bugs-To: m@mitchellh.com\n"
-"PO-Revision-Date: 2025-03-19 09:52+0100\n"
-"Last-Translator: Christoffer Tønnessen \n"
+"PO-Revision-Date: 2025-04-14 16:25+0200\n"
+"Last-Translator: cryptocode \n"
"Language-Team: Norwegian Bokmal \n"
"Language: nb\n"
"MIME-Version: 1.0\n"
@@ -162,7 +163,7 @@ msgstr "Avslutt"
#: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:6
#: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:6
msgid "Authorize Clipboard Access"
-msgstr "Gi tilgang til utklippstavle"
+msgstr "Gi tilgang til utklippstavlen"
#: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:7
msgid ""
@@ -187,7 +188,7 @@ msgid ""
"An application is attempting to write to the clipboard. The current "
"clipboard contents are shown below."
msgstr ""
-"En applikasjon er forsøker å skrive til utklippstavlen. Gjeldende "
+"En applikasjon forsøker å skrive til utklippstavlen. Gjeldende "
"utklippstavleinnhold er vist nedenfor."
#: src/apprt/gtk/ui/1.5/ccw-paste.blp:6
@@ -208,7 +209,7 @@ msgstr "Ghostty: Terminalinspektør"
#: src/apprt/gtk/Surface.zig:1243
msgid "Copied to clipboard"
-msgstr "Kopiert til utklippstavle"
+msgstr "Kopiert til utklippstavlen"
#: src/apprt/gtk/CloseDialog.zig:47
msgid "Close"
diff --git a/src/Surface.zig b/src/Surface.zig
index 89031a1b5..b9eb9e14a 100644
--- a/src/Surface.zig
+++ b/src/Surface.zig
@@ -930,6 +930,16 @@ pub fn handleMessage(self: *Surface, msg: Message) !void {
.present_surface => try self.presentSurface(),
.password_input => |v| try self.passwordInput(v),
+
+ .ring_bell => {
+ _ = self.rt_app.performAction(
+ .{ .surface = self },
+ .ring_bell,
+ {},
+ ) catch |err| {
+ log.warn("apprt failed to ring bell={}", .{err});
+ };
+ },
}
}
@@ -1031,9 +1041,64 @@ fn mouseRefreshLinks(
// If the position is outside our viewport, do nothing
if (pos.x < 0 or pos.y < 0) return;
+ // Update the last point that we checked for links so we don't
+ // recheck if the mouse moves some pixels to the same point.
self.mouse.link_point = pos_vp;
- if (try self.linkAtPos(pos)) |link| {
+ // We use an arena for everything below to make things easy to clean up.
+ // In the case we don't do any allocs this is very cheap to setup
+ // (effectively just struct init).
+ var arena = ArenaAllocator.init(self.alloc);
+ defer arena.deinit();
+ const alloc = arena.allocator();
+
+ // Get our link at the current position. This returns null if there
+ // isn't a link OR if we shouldn't be showing links for some reason
+ // (see further comments for cases).
+ const link_: ?apprt.action.MouseOverLink = link: {
+ // If we clicked and our mouse moved cells then we never
+ // highlight links until the mouse is unclicked. This follows
+ // standard macOS and Linux behavior where a click and drag cancels
+ // mouse actions.
+ const left_idx = @intFromEnum(input.MouseButton.left);
+ if (self.mouse.click_state[left_idx] == .press) click: {
+ const pin = self.mouse.left_click_pin orelse break :click;
+ const click_pt = self.io.terminal.screen.pages.pointFromPin(
+ .viewport,
+ pin.*,
+ ) orelse break :click;
+
+ if (!click_pt.coord().eql(pos_vp)) {
+ log.debug("mouse moved while left click held, ignoring link hover", .{});
+ break :link null;
+ }
+ }
+
+ const link = (try self.linkAtPos(pos)) orelse break :link null;
+ switch (link[0]) {
+ .open => {
+ const str = try self.io.terminal.screen.selectionString(alloc, .{
+ .sel = link[1],
+ .trim = false,
+ });
+ break :link .{ .url = str };
+ },
+
+ ._open_osc8 => {
+ // Show the URL in the status bar
+ const pin = link[1].start();
+ const uri = self.osc8URI(pin) orelse {
+ log.warn("failed to get URI for OSC8 hyperlink", .{});
+ break :link null;
+ };
+ break :link .{ .url = uri };
+ },
+ }
+ };
+
+ // If we found a link, setup our internal state and notify the
+ // apprt so it can highlight it.
+ if (link_) |link| {
self.renderer_state.mouse.point = pos_vp;
self.mouse.over_link = true;
self.renderer_state.terminal.screen.dirty.hyperlink_hover = true;
@@ -1042,38 +1107,18 @@ fn mouseRefreshLinks(
.mouse_shape,
.pointer,
);
-
- switch (link[0]) {
- .open => {
- const str = try self.io.terminal.screen.selectionString(self.alloc, .{
- .sel = link[1],
- .trim = false,
- });
- defer self.alloc.free(str);
- _ = try self.rt_app.performAction(
- .{ .surface = self },
- .mouse_over_link,
- .{ .url = str },
- );
- },
-
- ._open_osc8 => link: {
- // Show the URL in the status bar
- const pin = link[1].start();
- const uri = self.osc8URI(pin) orelse {
- log.warn("failed to get URI for OSC8 hyperlink", .{});
- break :link;
- };
- _ = try self.rt_app.performAction(
- .{ .surface = self },
- .mouse_over_link,
- .{ .url = uri },
- );
- },
- }
-
+ _ = try self.rt_app.performAction(
+ .{ .surface = self },
+ .mouse_over_link,
+ link,
+ );
try self.queueRender();
- } else if (over_link) {
+ return;
+ }
+
+ // No link, if we're previously over a link then we need to clear
+ // the over-link apprt state.
+ if (over_link) {
_ = try self.rt_app.performAction(
.{ .surface = self },
.mouse_shape,
@@ -1085,6 +1130,7 @@ fn mouseRefreshLinks(
.{ .url = "" },
);
try self.queueRender();
+ return;
}
}
diff --git a/src/apprt/action.zig b/src/apprt/action.zig
index 30cb2fa5e..30cbfb1e1 100644
--- a/src/apprt/action.zig
+++ b/src/apprt/action.zig
@@ -244,6 +244,8 @@ pub const Action = union(Key) {
/// Closes the currently focused window.
close_window,
+ ring_bell,
+
/// Sync with: ghostty_action_tag_e
pub const Key = enum(c_int) {
quit,
@@ -287,6 +289,7 @@ pub const Action = union(Key) {
reload_config,
config_change,
close_window,
+ ring_bell,
};
/// Sync with: ghostty_action_u
diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig
index 998f88022..c5ee802c4 100644
--- a/src/apprt/glfw.zig
+++ b/src/apprt/glfw.zig
@@ -246,6 +246,7 @@ pub const App = struct {
.toggle_maximize,
.prompt_title,
.reset_window_size,
+ .ring_bell,
=> {
log.info("unimplemented action={}", .{action});
return false;
diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig
index ddee49459..a14383ca3 100644
--- a/src/apprt/gtk/App.zig
+++ b/src/apprt/gtk/App.zig
@@ -484,6 +484,7 @@ pub fn performAction(
.prompt_title => try self.promptTitle(target),
.toggle_quick_terminal => return try self.toggleQuickTerminal(),
.secure_input => self.setSecureInput(target, value),
+ .ring_bell => try self.ringBell(target),
// Unimplemented
.close_all_windows,
@@ -775,6 +776,13 @@ fn toggleQuickTerminal(self: *App) !bool {
return true;
}
+fn ringBell(_: *App, target: apprt.Target) !void {
+ switch (target) {
+ .app => {},
+ .surface => |surface| try surface.rt_surface.ringBell(),
+ }
+}
+
fn quitTimer(self: *App, mode: apprt.action.QuitTimer) void {
switch (mode) {
.start => self.startQuitTimer(),
diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig
index fe05fa63b..e99fe29ce 100644
--- a/src/apprt/gtk/Surface.zig
+++ b/src/apprt/gtk/Surface.zig
@@ -2439,3 +2439,25 @@ pub fn setSecureInput(self: *Surface, value: apprt.action.SecureInput) void {
.toggle => self.is_secure_input = !self.is_secure_input,
}
}
+
+pub fn ringBell(self: *Surface) !void {
+ const features = self.app.config.@"bell-features";
+ const window = self.container.window() orelse {
+ log.warn("failed to ring bell: surface is not attached to any window", .{});
+ return;
+ };
+
+ // System beep
+ if (features.system) system: {
+ const surface = window.window.as(gtk.Native).getSurface() orelse break :system;
+ surface.beep();
+ }
+
+ // Mark tab as needing attention
+ if (self.container.tab()) |tab| tab: {
+ const page = window.notebook.getTabPage(tab) orelse break :tab;
+
+ // Need attention if we're not the currently selected tab
+ if (page.getSelected() == 0) page.setNeedsAttention(@intFromBool(true));
+ }
+}
diff --git a/src/apprt/gtk/TabView.zig b/src/apprt/gtk/TabView.zig
index 85a9bbcb2..ddd0951d2 100644
--- a/src/apprt/gtk/TabView.zig
+++ b/src/apprt/gtk/TabView.zig
@@ -114,9 +114,12 @@ pub fn gotoNthTab(self: *TabView, position: c_int) bool {
return true;
}
+pub fn getTabPage(self: *TabView, tab: *Tab) ?*adw.TabPage {
+ return self.tab_view.getPage(tab.box.as(gtk.Widget));
+}
+
pub fn getTabPosition(self: *TabView, tab: *Tab) ?c_int {
- const page = self.tab_view.getPage(tab.box.as(gtk.Widget));
- return self.tab_view.getPagePosition(page);
+ return self.tab_view.getPagePosition(self.getTabPage(tab) orelse return null);
}
pub fn gotoPreviousTab(self: *TabView, tab: *Tab) bool {
@@ -161,17 +164,16 @@ pub fn moveTab(self: *TabView, tab: *Tab, position: c_int) void {
}
pub fn reorderPage(self: *TabView, tab: *Tab, position: c_int) void {
- const page = self.tab_view.getPage(tab.box.as(gtk.Widget));
- _ = self.tab_view.reorderPage(page, position);
+ _ = self.tab_view.reorderPage(self.getTabPage(tab) orelse return, position);
}
pub fn setTabTitle(self: *TabView, tab: *Tab, title: [:0]const u8) void {
- const page = self.tab_view.getPage(tab.box.as(gtk.Widget));
+ const page = self.getTabPage(tab) orelse return;
page.setTitle(title.ptr);
}
pub fn setTabTooltip(self: *TabView, tab: *Tab, tooltip: [:0]const u8) void {
- const page = self.tab_view.getPage(tab.box.as(gtk.Widget));
+ const page = self.getTabPage(tab) orelse return;
page.setTooltip(tooltip.ptr);
}
@@ -203,8 +205,7 @@ pub fn closeTab(self: *TabView, tab: *Tab) void {
if (n > 1) self.forcing_close = false;
}
- const page = self.tab_view.getPage(tab.box.as(gtk.Widget));
- self.tab_view.closePage(page);
+ if (self.getTabPage(tab)) |page| self.tab_view.closePage(page);
// If we have no more tabs we close the window
if (self.nPages() == 0) {
@@ -260,6 +261,11 @@ fn adwTabViewCreateWindow(
fn adwSelectPage(_: *adw.TabView, _: *gobject.ParamSpec, self: *TabView) callconv(.C) void {
const page = self.tab_view.getSelectedPage() orelse return;
+
+ // If the tab was previously marked as needing attention
+ // (e.g. due to a bell character), we now unmark that
+ page.setNeedsAttention(@intFromBool(false));
+
const title = page.getTitle();
self.window.setTitle(std.mem.span(title));
}
diff --git a/src/apprt/surface.zig b/src/apprt/surface.zig
index f3fd71432..6de41c544 100644
--- a/src/apprt/surface.zig
+++ b/src/apprt/surface.zig
@@ -81,6 +81,9 @@ pub const Message = union(enum) {
/// The terminal has reported a change in the working directory.
pwd_change: WriteReq,
+ /// The terminal encountered a bell character.
+ ring_bell,
+
pub const ReportTitleStyle = enum {
csi_21_t,
diff --git a/src/config/Config.zig b/src/config/Config.zig
index a0d9275e9..f648e8a28 100644
--- a/src/config/Config.zig
+++ b/src/config/Config.zig
@@ -1861,6 +1861,22 @@ keybind: Keybinds = .{},
/// open terminals.
@"custom-shader-animation": CustomShaderAnimation = .true,
+/// The list of enabled features that are activated after encountering
+/// a bell character.
+///
+/// Valid values are:
+///
+/// * `system` (default)
+///
+/// Instructs the system to notify the user using built-in system functions.
+/// This could result in an audiovisual effect, a notification, or something
+/// else entirely. Changing these effects require altering system settings:
+/// for instance under the "Sound > Alert Sound" setting in GNOME,
+/// or the "Accessibility > System Bell" settings in KDE Plasma.
+///
+/// Currently only implemented on Linux.
+@"bell-features": BellFeatures = .{},
+
/// Control the in-app notifications that Ghostty shows.
///
/// On Linux (GTK), in-app notifications show up as toasts. Toasts appear
@@ -5691,6 +5707,11 @@ pub const AppNotifications = packed struct {
@"clipboard-copy": bool = true,
};
+/// See bell-features
+pub const BellFeatures = packed struct {
+ system: bool = false,
+};
+
/// See mouse-shift-capture
pub const MouseShiftCapture = enum {
false,
diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig
index 43d2888d2..299c7cd45 100644
--- a/src/termio/stream_handler.zig
+++ b/src/termio/stream_handler.zig
@@ -325,9 +325,8 @@ pub const StreamHandler = struct {
try self.terminal.printRepeat(count);
}
- pub fn bell(self: StreamHandler) !void {
- _ = self;
- log.info("BELL", .{});
+ pub fn bell(self: *StreamHandler) !void {
+ self.surfaceMessageWriter(.ring_bell);
}
pub fn backspace(self: *StreamHandler) !void {