mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-08-02 14:57:31 +03:00
Merge branch 'ghostty-org:main' into ms_MY
This commit is contained in:
162
.github/scripts/request_review.py
vendored
162
.github/scripts/request_review.py
vendored
@ -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: (
|
||||
"<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | "
|
||||
f"<level>{LEVEL_MAP[record['level'].name]}</level> | "
|
||||
"<cyan>{function}</cyan>:<cyan>{line}</cyan> - "
|
||||
"<level>{message}</level>\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__":
|
||||
|
@ -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 {
|
||||
|
@ -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 = "<group>"; };
|
||||
A57D79262C9C8798001D522E /* SecureInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureInput.swift; sourceTree = "<group>"; };
|
||||
A586167B2B7703CC009BDB1D /* fish */ = {isa = PBXFileReference; lastKnownFileType = folder; name = fish; path = "../zig-out/share/fish"; sourceTree = "<group>"; };
|
||||
A5874D982DAD751A00E83852 /* CGS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGS.swift; sourceTree = "<group>"; };
|
||||
A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSWindow+Extension.swift"; sourceTree = "<group>"; };
|
||||
A59444F629A2ED5200725BBA /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||
A59630962AEE163600D64628 /* HostingWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostingWindow.swift; sourceTree = "<group>"; };
|
||||
A59630992AEE1C6400D64628 /* Terminal.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = Terminal.xib; sourceTree = "<group>"; };
|
||||
@ -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 = "<group>";
|
||||
};
|
||||
A5874D9B2DAD781100E83852 /* Private */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A5874D982DAD751A00E83852 /* CGS.swift */,
|
||||
A5A2A3C92D4445E20033CF96 /* Dock.swift */,
|
||||
);
|
||||
path = Private;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 */,
|
||||
|
@ -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) {
|
||||
|
@ -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)
|
||||
|
@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
8
macos/Sources/Helpers/NSWindow+Extension.swift
Normal file
8
macos/Sources/Helpers/NSWindow+Extension.swift
Normal file
@ -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)
|
||||
}
|
||||
}
|
81
macos/Sources/Helpers/Private/CGS.swift
Normal file
81
macos/Sources/Helpers/Private/CGS.swift
Normal file
@ -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<CFArray>?
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
@ -4,13 +4,14 @@
|
||||
# Hanna Rose <hanna@hanna.lol>, 2025.
|
||||
# Uzair Aftab <uzaaft@outlook.com>, 2025.
|
||||
# Christoffer Tønnessen <christoffer@cto.gg>, 2025.
|
||||
# cryptocode <cryptocode@zolo.io>, 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 <christoffer@cto.gg>\n"
|
||||
"PO-Revision-Date: 2025-04-14 16:25+0200\n"
|
||||
"Last-Translator: cryptocode <cryptocode@zolo.io>\n"
|
||||
"Language-Team: Norwegian Bokmal <l10n-no@lister.huftis.org>\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"
|
||||
|
110
src/Surface.zig
110
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
@ -246,6 +246,7 @@ pub const App = struct {
|
||||
.toggle_maximize,
|
||||
.prompt_title,
|
||||
.reset_window_size,
|
||||
.ring_bell,
|
||||
=> {
|
||||
log.info("unimplemented action={}", .{action});
|
||||
return false;
|
||||
|
@ -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(),
|
||||
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
@ -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));
|
||||
}
|
||||
|
@ -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,
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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 {
|
||||
|
Reference in New Issue
Block a user