Merge branch 'ghostty-org:main' into ms_MY

This commit is contained in:
яυzαιηι
2025-04-15 21:49:04 +08:00
committed by GitHub
20 changed files with 453 additions and 110 deletions

View File

@ -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__":

View File

@ -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 {

View File

@ -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 */,

View File

@ -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) {

View File

@ -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)

View File

@ -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 }
}
}
}
}

View File

@ -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
}
}

View 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)
}
}

View 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)
}
}

View File

@ -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"

View File

@ -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;
}
}

View File

@ -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

View File

@ -246,6 +246,7 @@ pub const App = struct {
.toggle_maximize,
.prompt_title,
.reset_window_size,
.ring_bell,
=> {
log.info("unimplemented action={}", .{action});
return false;

View File

@ -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(),

View File

@ -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));
}
}

View File

@ -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));
}

View File

@ -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,

View File

@ -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,

View File

@ -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 {