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 {