Merge branch 'main' into ga-ghostty-1.3-translation

This commit is contained in:
Aindriú Mac Giolla Eoin
2026-02-18 09:47:04 +00:00
committed by GitHub
52 changed files with 2260 additions and 661 deletions

4
.github/VOUCHED.td vendored
View File

@@ -20,10 +20,12 @@
# "!denounce" or "!denounce [username]" on a discussion.
bennettp123
bernsno
bitigchi
bkircher
daiimus
doprz
elias8
eriksremess
filip7
hakonhagland
hqnna
@@ -34,6 +36,7 @@ mahnokropotkinvich
marrocco-simone
mikailmm
mitchellh
pan93412
peilingjiang
peterdavehello
pluiedev
@@ -43,4 +46,5 @@ prsweet
qwerasd205
rmunn
tweedbeetle
vlsi
yamshta

View File

@@ -17,8 +17,13 @@
# with this program; if not, write to the Free Software Foundation, Inc.,
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
from pathlib import Path
import gettext
from gi.repository import Nautilus, GObject, Gio
DOMAIN = "com.mitchellh.ghostty"
locale_dir = Path(__file__).absolute().parents[2] / "locale"
_ = gettext.translation(DOMAIN, locale_dir, fallback=True).gettext
def open_in_ghostty_activated(_menu, paths):
for path in paths:
@@ -45,7 +50,7 @@ def get_paths_to_open(files):
def get_items_for_files(name, files):
paths = get_paths_to_open(files)
if paths:
item = Nautilus.MenuItem(name=name, label='Open in Ghostty',
item = Nautilus.MenuItem(name=name, label=_('Open in Ghostty'),
icon='com.mitchellh.ghostty')
item.connect('activate', open_in_ghostty_activated, paths)
return [item]

View File

@@ -147,6 +147,7 @@
Features/Update/UpdatePopoverView.swift,
Features/Update/UpdateSimulator.swift,
Features/Update/UpdateViewModel.swift,
"Ghostty/Extensions/NSWorkspace+Ghostty.swift",
"Ghostty/FullscreenMode+Extension.swift",
Ghostty/Ghostty.Error.swift,
Ghostty/Ghostty.Event.swift,

View File

@@ -65,6 +65,7 @@ class AppDelegate: NSObject,
@IBOutlet private var menuReturnToDefaultSize: NSMenuItem?
@IBOutlet private var menuFloatOnTop: NSMenuItem?
@IBOutlet private var menuUseAsDefault: NSMenuItem?
@IBOutlet private var menuSetAsDefaultTerminal: NSMenuItem?
@IBOutlet private var menuIncreaseFontSize: NSMenuItem?
@IBOutlet private var menuDecreaseFontSize: NSMenuItem?
@@ -577,6 +578,7 @@ class AppDelegate: NSObject,
self.menuChangeTabTitle?.setImageIfDesired(systemSymbolName: "pencil.line")
self.menuTerminalInspector?.setImageIfDesired(systemSymbolName: "scope")
self.menuReadonly?.setImageIfDesired(systemSymbolName: "eye.fill")
self.menuSetAsDefaultTerminal?.setImageIfDesired(systemSymbolName: "star.fill")
self.menuToggleFullScreen?.setImageIfDesired(systemSymbolName: "square.arrowtriangle.4.outward")
self.menuToggleVisibility?.setImageIfDesired(systemSymbolName: "eye")
self.menuZoomSplit?.setImageIfDesired(systemSymbolName: "arrow.up.left.and.arrow.down.right")
@@ -1292,6 +1294,21 @@ extension AppDelegate {
ud.removeObject(forKey: key)
}
}
@IBAction func setAsDefaultTerminal(_ sender: NSMenuItem) {
do {
try NSWorkspace.shared.setGhosttyAsDefaultTerminal()
// Success - menu state will automatically update via validateMenuItem
} catch {
// Show error dialog
let alert = NSAlert()
alert.messageText = "Failed to Set Default Terminal"
alert.informativeText = "Ghostty could not be set as the default terminal application.\n\nError: \(error.localizedDescription)"
alert.alertStyle = .warning
alert.addButton(withTitle: "OK")
alert.runModal()
}
}
}
// MARK: NSMenuItemValidation
@@ -1299,6 +1316,12 @@ extension AppDelegate {
extension AppDelegate: NSMenuItemValidation {
func validateMenuItem(_ item: NSMenuItem) -> Bool {
switch item.action {
case #selector(setAsDefaultTerminal(_:)):
// Check if Ghostty is already the default terminal
let isDefault = NSWorkspace.shared.isGhosttyDefaultTerminal
// Disable menu item if already default (option A)
return !isDefault
case #selector(floatOnTop(_:)),
#selector(useAsDefault(_:)):
// Float on top items only active if the key window is a primary

View File

@@ -60,6 +60,7 @@
<outlet property="menuSelectSplitRight" destination="upj-mc-L7X" id="nLY-o1-lky"/>
<outlet property="menuSelectionForSearch" destination="TDN-42-Bu7" id="M04-1K-vze"/>
<outlet property="menuServices" destination="aQe-vS-j8Q" id="uWQ-Wo-T1L"/>
<outlet property="menuSetAsDefaultTerminal" destination="b1t-oB-7MI" id="6Eu-5G-OPo"/>
<outlet property="menuSplitDown" destination="UDZ-4y-6xL" id="ptr-mj-Azh"/>
<outlet property="menuSplitLeft" destination="Ppv-GP-lQU" id="Xd5-Cd-Jut"/>
<outlet property="menuSplitRight" destination="VUR-Ld-nLx" id="RxO-Zw-ovb"/>
@@ -109,6 +110,12 @@
<action selector="toggleSecureInput:" target="bbz-4X-AYv" id="vWx-z8-5Sy"/>
</connections>
</menuItem>
<menuItem title="Make Ghostty the Default Terminal" id="b1t-oB-7MI" userLabel="Set Ghostty as Default Terminal App">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="setAsDefaultTerminal:" target="bbz-4X-AYv" id="QHh-CA-Qho"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="4je-JR-u6R"/>
<menuItem title="Services" id="rJe-5J-bwL">
<modifierMask key="keyEquivalentModifierMask"/>

View File

@@ -0,0 +1,34 @@
import AppKit
import UniformTypeIdentifiers
extension NSWorkspace {
/// Checks if Ghostty is the default terminal application.
/// - Returns: True if Ghostty is the default application for handling public.unix-executable files.
var isGhosttyDefaultTerminal: Bool {
let ghosttyURL = Bundle.main.bundleURL
guard let defaultAppURL = defaultApplicationURL(forContentType: "public.unix-executable") else {
return false
}
// Compare bundle paths
return ghosttyURL.path == defaultAppURL.path
}
/// Sets Ghostty as the default terminal application.
/// - Throws: An error if the application bundle cannot be located or if setting the default fails.
func setGhosttyAsDefaultTerminal() throws {
let ghosttyURL = Bundle.main.bundleURL
// Create UTType for unix executables
guard let unixExecutableType = UTType("public.unix-executable") else {
throw NSError(
domain: "com.mitchellh.ghostty",
code: 2,
userInfo: [NSLocalizedDescriptionKey: "Could not create UTType for public.unix-executable"]
)
}
// Use NSWorkspace API to set the default application
// This API is available on macOS 12.0+, Ghostty supports 13.0+, so it's compatible
setDefaultApplication(at: ghosttyURL, toOpen: unixExecutableType)
}
}

View File

@@ -26,4 +26,5 @@ extension NSWorkspace {
guard let uti = UTType(filenameExtension: ext) else { return nil}
return defaultApplicationURL(forContentType: uti.identifier)
}
}

View File

@@ -0,0 +1,666 @@
import AppKit
import Testing
@testable import Ghostty
class MockView: NSView, Codable, Identifiable {
let id: UUID
init(id: UUID = UUID()) {
self.id = id
super.init(frame: .zero)
}
required init?(coder: NSCoder) { fatalError() }
enum CodingKeys: CodingKey { case id }
required init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.id = try c.decode(UUID.self, forKey: .id)
super.init(frame: .zero)
}
func encode(to encoder: Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
try c.encode(id, forKey: .id)
}
}
struct SplitTreeTests {
/// Creates a two-view horizontal split tree (view1 | view2).
private func makeHorizontalSplit() throws -> (SplitTree<MockView>, MockView, MockView) {
let view1 = MockView()
let view2 = MockView()
var tree = SplitTree<MockView>(view: view1)
tree = try tree.inserting(view: view2, at: view1, direction: .right)
return (tree, view1, view2)
}
// MARK: - Empty and Non-Empty
@Test func emptyTreeIsEmpty() {
let tree = SplitTree<MockView>()
#expect(tree.isEmpty)
}
@Test func nonEmptyTreeIsNotEmpty() {
let view1 = MockView()
let tree = SplitTree<MockView>(view: view1)
#expect(!tree.isEmpty)
}
@Test func isNotSplit() {
let view1 = MockView()
let tree = SplitTree<MockView>(view: view1)
#expect(!tree.isSplit)
}
@Test func isSplit() throws {
let (tree, _, _) = try makeHorizontalSplit()
#expect(tree.isSplit)
}
// MARK: - Contains and Find
@Test func treeContainsView() {
let view = MockView()
let tree = SplitTree<MockView>(view: view)
#expect(tree.contains(.leaf(view: view)))
}
@Test func treeDoesNotContainView() {
let view = MockView()
let tree = SplitTree<MockView>()
#expect(!tree.contains(.leaf(view: view)))
}
@Test func findsInsertedView() throws {
let (tree, view1, _) = try makeHorizontalSplit()
#expect((tree.find(id: view1.id) != nil))
}
@Test func doesNotFindUninsertedView() {
let view1 = MockView()
let view2 = MockView()
let tree = SplitTree<MockView>(view: view1)
#expect((tree.find(id: view2.id) == nil))
}
// MARK: - Removing and Replacing
@Test func treeDoesNotContainRemovedView() throws {
var (tree, view1, view2) = try makeHorizontalSplit()
tree = tree.removing(.leaf(view: view1))
#expect(!tree.contains(.leaf(view: view1)))
#expect(tree.contains(.leaf(view: view2)))
}
@Test func removingNonexistentNodeLeavesTreeUnchanged() {
let view1 = MockView()
let view2 = MockView()
let tree = SplitTree<MockView>(view: view1)
let result = tree.removing(.leaf(view: view2))
#expect(result.contains(.leaf(view: view1)))
#expect(!result.isEmpty)
}
@Test func replacingViewShouldRemoveAndInsertView() throws {
let view1 = MockView()
let view2 = MockView()
let view3 = MockView()
var tree = SplitTree<MockView>(view: view1)
tree = try tree.inserting(view: view2, at: view1, direction: .right)
#expect(tree.contains(.leaf(view: view2)))
let result = try tree.replacing(node: .leaf(view: view2), with: .leaf(view: view3))
#expect(result.contains(.leaf(view: view1)))
#expect(!result.contains(.leaf(view: view2)))
#expect(result.contains(.leaf(view: view3)))
}
@Test func replacingViewWithItselfShouldBeAValidOperation() throws {
let (tree, view1, view2) = try makeHorizontalSplit()
let result = try tree.replacing(node: .leaf(view: view2), with: .leaf(view: view2))
#expect(result.contains(.leaf(view: view1)))
#expect(result.contains(.leaf(view: view2)))
}
// MARK: - Focus Target
@Test func focusTargetOnEmptyTreeReturnsNil() {
let tree = SplitTree<MockView>()
let view = MockView()
let target = tree.focusTarget(for: .next, from: .leaf(view: view))
#expect(target == nil)
}
@Test func focusTargetShouldFindNextFocusedNode() throws {
let (tree, view1, view2) = try makeHorizontalSplit()
let target = tree.focusTarget(for: .next, from: .leaf(view: view1))
#expect(target === view2)
}
@Test func focusTargetShouldFindItselfWhenOnlyView() throws {
let view1 = MockView()
let tree = SplitTree<MockView>(view: view1)
let target = tree.focusTarget(for: .next, from: .leaf(view: view1))
#expect(target === view1)
}
// When there's no next view, wraps around to the first
@Test func focusTargetShouldHandleWrappingForNextNode() throws {
let (tree, view1, view2) = try makeHorizontalSplit()
let target = tree.focusTarget(for: .next, from: .leaf(view: view2))
#expect(target === view1)
}
@Test func focusTargetShouldFindPreviousFocusedNode() throws {
let (tree, view1, view2) = try makeHorizontalSplit()
let target = tree.focusTarget(for: .previous, from: .leaf(view: view2))
#expect(target === view1)
}
@Test func focusTargetShouldFindSpatialFocusedNode() throws {
let (tree, view1, view2) = try makeHorizontalSplit()
let target = tree.focusTarget(for: .spatial(.left), from: .leaf(view: view2))
#expect(target === view1)
}
// MARK: - Equalized
@Test func equalizedAdjustsRatioByLeafCount() throws {
let view1 = MockView()
let view2 = MockView()
let view3 = MockView()
var tree = SplitTree<MockView>(view: view1)
tree = try tree.inserting(view: view2, at: view1, direction: .right)
tree = try tree.inserting(view: view3, at: view2, direction: .right)
guard case .split(let before) = tree.root else {
Issue.record("unexpected node type")
return
}
#expect(abs(before.ratio - 0.5) < 0.001)
let equalized = tree.equalized()
if case .split(let s) = equalized.root {
#expect(abs(s.ratio - 1.0/3.0) < 0.001)
}
}
// MARK: - Resizing
@Test(arguments: [
// (resizeDirection, insertDirection, bounds, pixels, expectedRatio)
(SplitTree<MockView>.Spatial.Direction.right, SplitTree<MockView>.NewDirection.right,
CGRect(x: 0, y: 0, width: 1000, height: 500), UInt16(100), 0.6),
(.left, .right,
CGRect(x: 0, y: 0, width: 1000, height: 500), UInt16(50), 0.45),
(.down, .down,
CGRect(x: 0, y: 0, width: 500, height: 1000), UInt16(200), 0.7),
(.up, .down,
CGRect(x: 0, y: 0, width: 500, height: 1000), UInt16(50), 0.45),
])
func resizingAdjustsRatio(
resizeDirection: SplitTree<MockView>.Spatial.Direction,
insertDirection: SplitTree<MockView>.NewDirection,
bounds: CGRect,
pixels: UInt16,
expectedRatio: Double
) throws {
let view1 = MockView()
let view2 = MockView()
var tree = SplitTree<MockView>(view: view1)
tree = try tree.inserting(view: view2, at: view1, direction: insertDirection)
let resized = try tree.resizing(node: .leaf(view: view1), by: pixels, in: resizeDirection, with: bounds)
guard case .split(let s) = resized.root else {
Issue.record("unexpected node type")
return
}
#expect(abs(s.ratio - expectedRatio) < 0.001)
}
// MARK: - Codable
@Test func encodingAndDecodingPreservesTree() throws {
let (tree, view1, view2) = try makeHorizontalSplit()
let data = try JSONEncoder().encode(tree)
let decoded = try JSONDecoder().decode(SplitTree<MockView>.self, from: data)
#expect(decoded.find(id: view1.id) != nil)
#expect(decoded.find(id: view2.id) != nil)
#expect(decoded.isSplit)
}
@Test func encodingAndDecodingPreservesZoomedPath() throws {
let (tree, _, view2) = try makeHorizontalSplit()
let treeWithZoomed = SplitTree<MockView>(root: tree.root, zoomed: .leaf(view: view2))
let data = try JSONEncoder().encode(treeWithZoomed)
let decoded = try JSONDecoder().decode(SplitTree<MockView>.self, from: data)
#expect(decoded.zoomed != nil)
if case .leaf(let zoomedView) = decoded.zoomed! {
#expect(zoomedView.id == view2.id)
} else {
Issue.record("unexpected node type")
}
}
// MARK: - Collection Conformance
@Test func treeIteratesLeavesInOrder() throws {
let view1 = MockView()
let view2 = MockView()
let view3 = MockView()
var tree = SplitTree<MockView>(view: view1)
tree = try tree.inserting(view: view2, at: view1, direction: .right)
tree = try tree.inserting(view: view3, at: view2, direction: .right)
#expect(tree.startIndex == 0)
#expect(tree.endIndex == 3)
#expect(tree.index(after: 0) == 1)
#expect(tree[0] === view1)
#expect(tree[1] === view2)
#expect(tree[2] === view3)
var ids: [UUID] = []
for view in tree {
ids.append(view.id)
}
#expect(ids == [view1.id, view2.id, view3.id])
}
@Test func emptyTreeCollectionProperties() {
let tree = SplitTree<MockView>()
#expect(tree.startIndex == 0)
#expect(tree.endIndex == 0)
var count = 0
for _ in tree {
count += 1
}
#expect(count == 0)
}
// MARK: - Structural Identity
@Test func structuralIdentityIsReflexive() throws {
let (tree, _, _) = try makeHorizontalSplit()
#expect(tree.structuralIdentity == tree.structuralIdentity)
}
@Test func structuralIdentityComparesShapeNotRatio() throws {
let (tree, view1, _) = try makeHorizontalSplit()
let bounds = CGRect(x: 0, y: 0, width: 1000, height: 500)
let resized = try tree.resizing(node: .leaf(view: view1), by: 100, in: .right, with: bounds)
#expect(tree.structuralIdentity == resized.structuralIdentity)
}
@Test func structuralIdentityForDifferentStructures() throws {
let view1 = MockView()
let view2 = MockView()
let view3 = MockView()
var tree = SplitTree<MockView>(view: view1)
tree = try tree.inserting(view: view2, at: view1, direction: .right)
let expanded = try tree.inserting(view: view3, at: view2, direction: .down)
#expect(tree.structuralIdentity != expanded.structuralIdentity)
}
@Test func structuralIdentityIdentifiesDifferentOrdersShapes() throws {
let (tree, _, _) = try makeHorizontalSplit()
let (otherTree, _, _) = try makeHorizontalSplit()
#expect(tree.structuralIdentity != otherTree.structuralIdentity)
}
// MARK: - View Bounds
@Test func viewBoundsReturnsLeafViewSize() {
let view1 = MockView()
view1.frame = NSRect(x: 0, y: 0, width: 500, height: 300)
let tree = SplitTree<MockView>(view: view1)
let bounds = tree.viewBounds()
#expect(bounds.width == 500)
#expect(bounds.height == 300)
}
@Test func viewBoundsReturnsZeroForEmptyTree() {
let tree = SplitTree<MockView>()
let bounds = tree.viewBounds()
#expect(bounds.width == 0)
#expect(bounds.height == 0)
}
@Test func viewBoundsHorizontalSplit() throws {
let view1 = MockView()
let view2 = MockView()
view1.frame = NSRect(x: 0, y: 0, width: 400, height: 300)
view2.frame = NSRect(x: 0, y: 0, width: 200, height: 500)
var tree = SplitTree<MockView>(view: view1)
tree = try tree.inserting(view: view2, at: view1, direction: .right)
let bounds = tree.viewBounds()
#expect(bounds.width == 600)
#expect(bounds.height == 500)
}
@Test func viewBoundsVerticalSplit() throws {
let view1 = MockView()
let view2 = MockView()
view1.frame = NSRect(x: 0, y: 0, width: 300, height: 200)
view2.frame = NSRect(x: 0, y: 0, width: 500, height: 400)
var tree = SplitTree<MockView>(view: view1)
tree = try tree.inserting(view: view2, at: view1, direction: .down)
let bounds = tree.viewBounds()
#expect(bounds.width == 500)
#expect(bounds.height == 600)
}
// MARK: - Node
@Test func nodeFindsLeaf() {
let view1 = MockView()
let tree = SplitTree<MockView>(view: view1)
let node = tree.root?.node(view: view1)
#expect(node != nil)
#expect(node == .leaf(view: view1))
}
@Test func nodeFindsLeavesInSplitTree() throws {
let (tree, view1, view2) = try makeHorizontalSplit()
#expect(tree.root?.node(view: view1) == .leaf(view: view1))
#expect(tree.root?.node(view: view2) == .leaf(view: view2))
}
@Test func nodeReturnsNilForMissingView() {
let view1 = MockView()
let view2 = MockView()
let tree = SplitTree<MockView>(view: view1)
#expect(tree.root?.node(view: view2) == nil)
}
@Test func resizingUpdatesRatio() throws {
let (tree, _, _) = try makeHorizontalSplit()
guard case .split(let s) = tree.root else {
Issue.record("unexpected node type")
return
}
let resized = SplitTree<MockView>.Node.split(s).resizing(to: 0.7)
guard case .split(let resizedSplit) = resized else {
Issue.record("unexpected node type")
return
}
#expect(abs(resizedSplit.ratio - 0.7) < 0.001)
}
@Test func resizingLeavesLeafUnchanged() {
let view1 = MockView()
let tree = SplitTree<MockView>(view: view1)
guard let root = tree.root else {
Issue.record("expected non-empty tree")
return
}
let resized = root.resizing(to: 0.7)
#expect(resized == root)
}
// MARK: - Spatial
@Test(arguments: [
(SplitTree<MockView>.Spatial.Direction.left, SplitTree<MockView>.NewDirection.right),
(.right, .right),
(.up, .down),
(.down, .down),
])
func doesBorderEdge(
side: SplitTree<MockView>.Spatial.Direction,
insertDirection: SplitTree<MockView>.NewDirection
) throws {
let view1 = MockView()
let view2 = MockView()
var tree = SplitTree<MockView>(view: view1)
tree = try tree.inserting(view: view2, at: view1, direction: insertDirection)
let spatial = tree.root!.spatial(within: CGSize(width: 1000, height: 500))
// view1 borders left/up; view2 borders right/down
let (borderView, nonBorderView): (MockView, MockView) =
(side == .right || side == .down) ? (view2, view1) : (view1, view2)
#expect(spatial.doesBorder(side: side, from: .leaf(view: borderView)))
#expect(!spatial.doesBorder(side: side, from: .leaf(view: nonBorderView)))
}
// MARK: - Calculate View Bounds
@Test func calculatesViewBoundsForSingleLeaf() {
let view1 = MockView()
let tree = SplitTree<MockView>(view: view1)
guard let root = tree.root else {
Issue.record("expected non-empty tree")
return
}
let bounds = CGRect(x: 0, y: 0, width: 1000, height: 500)
let result = root.calculateViewBounds(in: bounds)
#expect(result.count == 1)
#expect(result[0].view === view1)
#expect(result[0].bounds == bounds)
}
@Test func calculatesViewBoundsHorizontalSplit() throws {
let (tree, view1, view2) = try makeHorizontalSplit()
guard let root = tree.root else {
Issue.record("expected non-empty tree")
return
}
let bounds = CGRect(x: 0, y: 0, width: 1000, height: 500)
let result = root.calculateViewBounds(in: bounds)
#expect(result.count == 2)
let leftBounds = result.first { $0.view === view1 }!.bounds
let rightBounds = result.first { $0.view === view2 }!.bounds
#expect(leftBounds == CGRect(x: 0, y: 0, width: 500, height: 500))
#expect(rightBounds == CGRect(x: 500, y: 0, width: 500, height: 500))
}
@Test func calculatesViewBoundsVerticalSplit() throws {
let view1 = MockView()
let view2 = MockView()
var tree = SplitTree<MockView>(view: view1)
tree = try tree.inserting(view: view2, at: view1, direction: .down)
guard let root = tree.root else {
Issue.record("expected non-empty tree")
return
}
let bounds = CGRect(x: 0, y: 0, width: 500, height: 1000)
let result = root.calculateViewBounds(in: bounds)
#expect(result.count == 2)
let topBounds = result.first { $0.view === view1 }!.bounds
let bottomBounds = result.first { $0.view === view2 }!.bounds
#expect(topBounds == CGRect(x: 0, y: 500, width: 500, height: 500))
#expect(bottomBounds == CGRect(x: 0, y: 0, width: 500, height: 500))
}
@Test func calculateViewBoundsCustomRatio() throws {
let (tree, view1, view2) = try makeHorizontalSplit()
guard case .split(let s) = tree.root else {
Issue.record("unexpected node type")
return
}
let resizedRoot = SplitTree<MockView>.Node.split(s).resizing(to: 0.3)
let container = CGRect(x: 0, y: 0, width: 1000, height: 400)
let result = resizedRoot.calculateViewBounds(in: container)
#expect(result.count == 2)
let leftBounds = result.first { $0.view === view1 }!.bounds
let rightBounds = result.first { $0.view === view2 }!.bounds
#expect(leftBounds.width == 300) // 0.3 * 1000
#expect(rightBounds.width == 700) // 0.7 * 1000
#expect(rightBounds.minX == 300)
}
@Test func calculateViewBoundsGrid() throws {
let view1 = MockView()
let view2 = MockView()
let view3 = MockView()
let view4 = MockView()
var tree = SplitTree<MockView>(view: view1)
tree = try tree.inserting(view: view2, at: view1, direction: .right)
tree = try tree.inserting(view: view3, at: view1, direction: .down)
tree = try tree.inserting(view: view4, at: view2, direction: .down)
guard let root = tree.root else {
Issue.record("expected non-empty tree")
return
}
let container = CGRect(x: 0, y: 0, width: 1000, height: 800)
let result = root.calculateViewBounds(in: container)
#expect(result.count == 4)
let b1 = result.first { $0.view === view1 }!.bounds
let b2 = result.first { $0.view === view2 }!.bounds
let b3 = result.first { $0.view === view3 }!.bounds
let b4 = result.first { $0.view === view4 }!.bounds
#expect(b1 == CGRect(x: 0, y: 400, width: 500, height: 400)) // top-left
#expect(b2 == CGRect(x: 500, y: 400, width: 500, height: 400)) // top-right
#expect(b3 == CGRect(x: 0, y: 0, width: 500, height: 400)) // bottom-left
#expect(b4 == CGRect(x: 500, y: 0, width: 500, height: 400)) // bottom-right
}
@Test(arguments: [
(SplitTree<MockView>.Spatial.Direction.right, SplitTree<MockView>.NewDirection.right),
(.left, .right),
(.down, .down),
(.up, .down),
])
func slotsFromNode(
direction: SplitTree<MockView>.Spatial.Direction,
insertDirection: SplitTree<MockView>.NewDirection
) throws {
let view1 = MockView()
let view2 = MockView()
var tree = SplitTree<MockView>(view: view1)
tree = try tree.inserting(view: view2, at: view1, direction: insertDirection)
let spatial = tree.root!.spatial(within: CGSize(width: 1000, height: 500))
// look from view1 toward view2 for right/down, from view2 toward view1 for left/up
let (fromView, expectedView): (MockView, MockView) =
(direction == .right || direction == .down) ? (view1, view2) : (view2, view1)
let slots = spatial.slots(in: direction, from: .leaf(view: fromView))
#expect(slots.count == 1)
#expect(slots[0].node == .leaf(view: expectedView))
}
@Test func slotsGridFromTopLeft() throws {
let view1 = MockView()
let view2 = MockView()
let view3 = MockView()
let view4 = MockView()
var tree = SplitTree<MockView>(view: view1)
tree = try tree.inserting(view: view2, at: view1, direction: .right)
tree = try tree.inserting(view: view3, at: view1, direction: .down)
tree = try tree.inserting(view: view4, at: view2, direction: .down)
let spatial = tree.root!.spatial(within: CGSize(width: 1000, height: 800))
let rightSlots = spatial.slots(in: .right, from: .leaf(view: view1))
let downSlots = spatial.slots(in: .down, from: .leaf(view: view1))
// slots() returns both split nodes and leaves; split nodes can tie on distance
#expect(rightSlots.contains { $0.node == .leaf(view: view2) })
#expect(downSlots.contains { $0.node == .leaf(view: view3) })
}
@Test func slotsGridFromBottomRight() throws {
let view1 = MockView()
let view2 = MockView()
let view3 = MockView()
let view4 = MockView()
var tree = SplitTree<MockView>(view: view1)
tree = try tree.inserting(view: view2, at: view1, direction: .right)
tree = try tree.inserting(view: view3, at: view1, direction: .down)
tree = try tree.inserting(view: view4, at: view2, direction: .down)
let spatial = tree.root!.spatial(within: CGSize(width: 1000, height: 800))
let leftSlots = spatial.slots(in: .left, from: .leaf(view: view4))
let upSlots = spatial.slots(in: .up, from: .leaf(view: view4))
#expect(leftSlots.contains { $0.node == .leaf(view: view3) })
#expect(upSlots.contains { $0.node == .leaf(view: view2) })
}
@Test func slotsReturnsEmptyWhenNoNodesInDirection() throws {
let (tree, view1, view2) = try makeHorizontalSplit()
let spatial = tree.root!.spatial(within: CGSize(width: 1000, height: 500))
#expect(spatial.slots(in: .left, from: .leaf(view: view1)).isEmpty)
#expect(spatial.slots(in: .right, from: .leaf(view: view2)).isEmpty)
#expect(spatial.slots(in: .up, from: .leaf(view: view1)).isEmpty)
#expect(spatial.slots(in: .down, from: .leaf(view: view2)).isEmpty)
}
// Set/Dictionary usage is the only path that exercises StructuralIdentity.hash(into:)
@Test func structuralIdentityHashableBehavior() throws {
let (tree, _, _) = try makeHorizontalSplit()
let id = tree.structuralIdentity
#expect(id == id)
var seen: Set<SplitTree<MockView>.StructuralIdentity> = []
seen.insert(id)
seen.insert(id)
#expect(seen.count == 1)
var cache: [SplitTree<MockView>.StructuralIdentity: String] = [:]
cache[id] = "two-pane"
#expect(cache[id] == "two-pane")
}
@Test func nodeStructuralIdentityInSet() throws {
let (tree, _, _) = try makeHorizontalSplit()
guard case .split(let s) = tree.root else {
Issue.record("unexpected node type")
return
}
var nodeIds: Set<SplitTree<MockView>.Node.StructuralIdentity> = []
nodeIds.insert(tree.root!.structuralIdentity)
nodeIds.insert(s.left.structuralIdentity)
nodeIds.insert(s.right.structuralIdentity)
#expect(nodeIds.count == 3)
}
@Test func nodeStructuralIdentityDistinguishesLeaves() throws {
let (tree, _, _) = try makeHorizontalSplit()
guard case .split(let s) = tree.root else {
Issue.record("unexpected node type")
return
}
var nodeIds: Set<SplitTree<MockView>.Node.StructuralIdentity> = []
nodeIds.insert(s.left.structuralIdentity)
nodeIds.insert(s.right.structuralIdentity)
#expect(nodeIds.count == 2)
}
}

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: com.mitchellh.ghostty\n"
"Report-Msgid-Bugs-To: m@mitchellh.com\n"
"POT-Creation-Date: 2026-02-16 23:06+0100\n"
"POT-Creation-Date: 2026-02-17 23:16+0100\n"
"PO-Revision-Date: 2026-02-09 22:07+0200\n"
"Last-Translator: reo101 <pavel.atanasov2001@gmail.com>\n"
"Language-Team: Bulgarian <dict@ludost.net>\n"
@@ -18,6 +18,10 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: dist/linux/ghostty_nautilus.py:53
msgid "Open in Ghostty"
msgstr ""
#: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:197

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: com.mitchellh.ghostty\n"
"Report-Msgid-Bugs-To: m@mitchellh.com\n"
"POT-Creation-Date: 2026-02-16 23:06+0100\n"
"POT-Creation-Date: 2026-02-17 23:16+0100\n"
"PO-Revision-Date: 2025-08-24 19:22+0200\n"
"Last-Translator: Kristofer Soler "
"<31729650+KristoferSoler@users.noreply.github.com>\n"
@@ -19,6 +19,10 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: dist/linux/ghostty_nautilus.py:53
msgid "Open in Ghostty"
msgstr ""
#: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:197

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: com.mitchellh.ghostty\n"
"Report-Msgid-Bugs-To: m@mitchellh.com\n"
"POT-Creation-Date: 2026-02-16 23:06+0100\n"
"POT-Creation-Date: 2026-02-17 23:16+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -17,6 +17,10 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: dist/linux/ghostty_nautilus.py:53
msgid "Open in Ghostty"
msgstr ""
#: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:197

View File

@@ -10,7 +10,7 @@ msgid ""
msgstr ""
"Project-Id-Version: com.mitchellh.ghostty\n"
"Report-Msgid-Bugs-To: m@mitchellh.com\n"
"POT-Creation-Date: 2026-02-16 23:06+0100\n"
"POT-Creation-Date: 2026-02-17 23:16+0100\n"
"PO-Revision-Date: 2026-02-13 08:05+0100\n"
"Last-Translator: Klaus Hipp <khipp@users.noreply.github.com>\n"
"Language-Team: German <translation-team-de@lists.sourceforge.net>\n"
@@ -20,6 +20,10 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: dist/linux/ghostty_nautilus.py:53
msgid "Open in Ghostty"
msgstr ""
#: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:197

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: com.mitchellh.ghostty\n"
"Report-Msgid-Bugs-To: m@mitchellh.com\n"
"POT-Creation-Date: 2026-02-16 23:06+0100\n"
"POT-Creation-Date: 2026-02-17 23:16+0100\n"
"PO-Revision-Date: 2026-02-09 17:50-0300\n"
"Last-Translator: Alan Moyano <alanmoyano203@gmail.com>\n"
"Language-Team: Argentinian <es@tp.org.es>\n"
@@ -17,6 +17,10 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: dist/linux/ghostty_nautilus.py:53
msgid "Open in Ghostty"
msgstr ""
#: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:197

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: com.mitchellh.ghostty\n"
"Report-Msgid-Bugs-To: m@mitchellh.com\n"
"POT-Creation-Date: 2026-02-16 23:06+0100\n"
"POT-Creation-Date: 2026-02-17 23:16+0100\n"
"PO-Revision-Date: 2026-02-12 17:46+0200\n"
"Last-Translator: Miguel Peredo <miguelp@quientienemail.com>\n"
"Language-Team: Spanish <es@tp.org.es>\n"
@@ -17,6 +17,10 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: dist/linux/ghostty_nautilus.py:53
msgid "Open in Ghostty"
msgstr ""
#: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:197

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: com.mitchellh.ghostty\n"
"Report-Msgid-Bugs-To: m@mitchellh.com\n"
"POT-Creation-Date: 2026-02-16 23:06+0100\n"
"POT-Creation-Date: 2026-02-17 23:16+0100\n"
"PO-Revision-Date: 2026-02-09 21:18+0200\n"
"Last-Translator: Gerry Agbobada <gerry@gagbo.net>\n"
"Language-Team: French <traduc@traduc.org>\n"
@@ -17,6 +17,10 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
#: dist/linux/ghostty_nautilus.py:53
msgid "Open in Ghostty"
msgstr ""
#: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:197

View File

@@ -17,6 +17,10 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=3; plural=n==1 ? 0 : n==2 ? 1 : 2;\n"
#: dist/linux/ghostty_nautilus.py:53
msgid "Open in Ghostty"
msgstr ""
#: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:197

View File

@@ -9,7 +9,7 @@ msgid ""
msgstr ""
"Project-Id-Version: com.mitchellh.ghostty\n"
"Report-Msgid-Bugs-To: m@mitchellh.com\n"
"POT-Creation-Date: 2026-02-16 23:06+0100\n"
"POT-Creation-Date: 2026-02-17 23:16+0100\n"
"PO-Revision-Date: 2026-02-11 22:45+0300\n"
"Last-Translator: Sl (Shahaf Levi), Sl's Repository Ltd "
"<ghostty@slsrepo.com>\n"
@@ -20,6 +20,10 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: dist/linux/ghostty_nautilus.py:53
msgid "Open in Ghostty"
msgstr ""
#: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:197

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: com.mitchellh.ghostty\n"
"Report-Msgid-Bugs-To: m@mitchellh.com\n"
"POT-Creation-Date: 2026-02-16 23:06+0100\n"
"POT-Creation-Date: 2026-02-17 23:16+0100\n"
"PO-Revision-Date: 2026-02-10 22:25+0200\n"
"Last-Translator: Filip7 <filipm7@protonmail.com>\n"
"Language-Team: Croatian <lokalizacija@linux.hr>\n"
@@ -19,6 +19,10 @@ msgstr ""
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
#: dist/linux/ghostty_nautilus.py:53
msgid "Open in Ghostty"
msgstr ""
#: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:197

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: com.mitchellh.ghostty\n"
"Report-Msgid-Bugs-To: m@mitchellh.com\n"
"POT-Creation-Date: 2026-02-16 23:06+0100\n"
"POT-Creation-Date: 2026-02-17 23:16+0100\n"
"PO-Revision-Date: 2026-02-10 18:32+0200\n"
"Last-Translator: Balázs Szücs <bszucs1209@gmail.com>\n"
"Language-Team: Hungarian <translation-team-hu@lists.sourceforge.net>\n"
@@ -17,6 +17,10 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: dist/linux/ghostty_nautilus.py:53
msgid "Open in Ghostty"
msgstr ""
#: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:197

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: com.mitchellh.ghostty\n"
"Report-Msgid-Bugs-To: m@mitchellh.com\n"
"POT-Creation-Date: 2026-02-16 23:06+0100\n"
"POT-Creation-Date: 2026-02-17 23:16+0100\n"
"PO-Revision-Date: 2025-08-01 10:15+0700\n"
"Last-Translator: Mikail Muzakki <mikailmmuzakki@gmail.com>\n"
"Language-Team: Indonesian <translation-team-id@lists.sourceforge.net>\n"
@@ -17,6 +17,10 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: dist/linux/ghostty_nautilus.py:53
msgid "Open in Ghostty"
msgstr ""
#: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:197

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: com.mitchellh.ghostty\n"
"Report-Msgid-Bugs-To: m@mitchellh.com\n"
"POT-Creation-Date: 2026-02-16 23:06+0100\n"
"POT-Creation-Date: 2026-02-17 23:16+0100\n"
"PO-Revision-Date: 2025-09-06 19:40+0200\n"
"Last-Translator: Giacomo Bettini <giaco.bettini@gmail.com>\n"
"Language-Team: Italian <tp@lists.linux.it>\n"
@@ -18,6 +18,10 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: dist/linux/ghostty_nautilus.py:53
msgid "Open in Ghostty"
msgstr ""
#: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:197

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: com.mitchellh.ghostty\n"
"Report-Msgid-Bugs-To: m@mitchellh.com\n"
"POT-Creation-Date: 2026-02-16 23:06+0100\n"
"POT-Creation-Date: 2026-02-17 23:16+0100\n"
"PO-Revision-Date: 2026-02-11 12:02+0900\n"
"Last-Translator: Takayuki Nagatomi <tnagatomi@okweird.net>\n"
"Language-Team: Japanese\n"
@@ -18,6 +18,10 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=1; plural=0;\n"
#: dist/linux/ghostty_nautilus.py:53
msgid "Open in Ghostty"
msgstr ""
#: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:197

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: com.mitchellh.ghostty\n"
"Report-Msgid-Bugs-To: m@mitchellh.com\n"
"POT-Creation-Date: 2026-02-16 23:06+0100\n"
"POT-Creation-Date: 2026-02-17 23:16+0100\n"
"PO-Revision-Date: 2026-02-11 12:50+0900\n"
"Last-Translator: GyuYong Jung <obliviscence@gmail.com>\n"
"Language-Team: Korean <translation-team-ko@googlegroups.com>\n"
@@ -17,6 +17,10 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=1; plural=0;\n"
#: dist/linux/ghostty_nautilus.py:53
msgid "Open in Ghostty"
msgstr ""
#: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:197

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: com.mitchellh.ghostty\n"
"Report-Msgid-Bugs-To: m@mitchellh.com\n"
"POT-Creation-Date: 2026-02-16 23:06+0100\n"
"POT-Creation-Date: 2026-02-17 23:16+0100\n"
"PO-Revision-Date: 2026-02-10 08:14+0100\n"
"Last-Translator: Tadas Lotuzas <tdslot@gmail.com>\n"
"Language-Team: Language LT\n"
@@ -16,6 +16,10 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: dist/linux/ghostty_nautilus.py:53
msgid "Open in Ghostty"
msgstr ""
#: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:197

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: com.mitchellh.ghostty\n"
"Report-Msgid-Bugs-To: m@mitchellh.com\n"
"POT-Creation-Date: 2026-02-16 23:06+0100\n"
"POT-Creation-Date: 2026-02-17 23:16+0100\n"
"PO-Revision-Date: 2026-02-09 03:24+0200\n"
"Last-Translator: Ēriks Remess <eriks@remess.lv>\n"
"Language-Team: Latvian\n"
@@ -17,6 +17,10 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n!=0 ? 1 : 2);\n"
#: dist/linux/ghostty_nautilus.py:53
msgid "Open in Ghostty"
msgstr ""
#: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:197

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: com.mitchellh.ghostty\n"
"Report-Msgid-Bugs-To: m@mitchellh.com\n"
"POT-Creation-Date: 2026-02-16 23:06+0100\n"
"POT-Creation-Date: 2026-02-17 23:16+0100\n"
"PO-Revision-Date: 2026-02-12 17:00+0100\n"
"Last-Translator: Andrej Daskalov <andrej.daskalov@gmail.com>\n"
"Language-Team: Macedonian\n"
@@ -17,6 +17,10 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: dist/linux/ghostty_nautilus.py:53
msgid "Open in Ghostty"
msgstr ""
#: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:197

View File

@@ -10,7 +10,7 @@ msgid ""
msgstr ""
"Project-Id-Version: com.mitchellh.ghostty\n"
"Report-Msgid-Bugs-To: m@mitchellh.com\n"
"POT-Creation-Date: 2026-02-16 23:06+0100\n"
"POT-Creation-Date: 2026-02-17 23:16+0100\n"
"PO-Revision-Date: 2026-02-12 15:50+0000\n"
"Last-Translator: Hanna Rose <me@hanna.lol>\n"
"Language-Team: Norwegian Bokmal <l10n-no@lister.huftis.org>\n"
@@ -20,6 +20,10 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: dist/linux/ghostty_nautilus.py:53
msgid "Open in Ghostty"
msgstr ""
#: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:197

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: com.mitchellh.ghostty\n"
"Report-Msgid-Bugs-To: m@mitchellh.com\n"
"POT-Creation-Date: 2026-02-16 23:06+0100\n"
"POT-Creation-Date: 2026-02-17 23:16+0100\n"
"PO-Revision-Date: 2026-02-09 20:39+0100\n"
"Last-Translator: Nico Geesink <geesinknico@gmail.com>\n"
"Language-Team: Dutch <vertaling@vrijschrift.org>\n"
@@ -18,6 +18,10 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: dist/linux/ghostty_nautilus.py:53
msgid "Open in Ghostty"
msgstr ""
#: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:197

View File

@@ -9,7 +9,7 @@ msgid ""
msgstr ""
"Project-Id-Version: com.mitchellh.ghostty\n"
"Report-Msgid-Bugs-To: m@mitchellh.com\n"
"POT-Creation-Date: 2026-02-16 23:06+0100\n"
"POT-Creation-Date: 2026-02-17 23:16+0100\n"
"PO-Revision-Date: 2026-02-11 14:12+0100\n"
"Last-Translator: trag1c <dev@jakubr.me>\n"
"Language-Team: Polish <translation-team-pl@lists.sourceforge.net>\n"
@@ -20,6 +20,10 @@ msgstr ""
"Plural-Forms: nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 "
"|| n%100>=20) ? 1 : 2);\n"
#: dist/linux/ghostty_nautilus.py:53
msgid "Open in Ghostty"
msgstr ""
#: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:197

View File

@@ -10,7 +10,7 @@ msgid ""
msgstr ""
"Project-Id-Version: com.mitchellh.ghostty\n"
"Report-Msgid-Bugs-To: m@mitchellh.com\n"
"POT-Creation-Date: 2026-02-16 23:06+0100\n"
"POT-Creation-Date: 2026-02-17 23:16+0100\n"
"PO-Revision-Date: 2025-09-15 13:57-0300\n"
"Last-Translator: Nilton Perim Neto <niltonperimneto@gmail.com>\n"
"Language-Team: Brazilian Portuguese <ldpbr-"
@@ -21,6 +21,10 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
#: dist/linux/ghostty_nautilus.py:53
msgid "Open in Ghostty"
msgstr ""
#: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:197

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: com.mitchellh.ghostty\n"
"Report-Msgid-Bugs-To: m@mitchellh.com\n"
"POT-Creation-Date: 2026-02-16 23:06+0100\n"
"POT-Creation-Date: 2026-02-17 23:16+0100\n"
"PO-Revision-Date: 2025-09-03 01:50+0300\n"
"Last-Translator: Ivan Bastrakov <bastaynav@proton.me>\n"
"Language-Team: Russian <gnu@d07.ru>\n"
@@ -19,6 +19,10 @@ msgstr ""
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
#: dist/linux/ghostty_nautilus.py:53
msgid "Open in Ghostty"
msgstr ""
#: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:197

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: com.mitchellh.ghostty\n"
"Report-Msgid-Bugs-To: m@mitchellh.com\n"
"POT-Creation-Date: 2026-02-16 23:06+0100\n"
"POT-Creation-Date: 2026-02-17 23:16+0100\n"
"PO-Revision-Date: 2026-02-09 22:18+0300\n"
"Last-Translator: Emir SARI <emir_sari@icloud.com>\n"
"Language-Team: Turkish\n"
@@ -17,6 +17,10 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: dist/linux/ghostty_nautilus.py:53
msgid "Open in Ghostty"
msgstr ""
#: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:197

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: com.mitchellh.ghostty\n"
"Report-Msgid-Bugs-To: m@mitchellh.com\n"
"POT-Creation-Date: 2026-02-16 23:06+0100\n"
"POT-Creation-Date: 2026-02-17 23:16+0100\n"
"PO-Revision-Date: 2026-02-09 21:03+0100\n"
"Last-Translator: Volodymyr Chernetskyi "
"<19735328+chernetskyi@users.noreply.github.com>\n"
@@ -19,6 +19,10 @@ msgstr ""
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
#: dist/linux/ghostty_nautilus.py:53
msgid "Open in Ghostty"
msgstr ""
#: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:197

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: com.mitchellh.ghostty\n"
"Report-Msgid-Bugs-To: m@mitchellh.com\n"
"POT-Creation-Date: 2026-02-16 23:06+0100\n"
"POT-Creation-Date: 2026-02-17 23:16+0100\n"
"PO-Revision-Date: 2026-02-12 01:56+0800\n"
"Last-Translator: Leah <hi@pluie.me>\n"
"Language-Team: Chinese (simplified) <i18n-zh@googlegroups.com>\n"
@@ -17,6 +17,10 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: dist/linux/ghostty_nautilus.py:53
msgid "Open in Ghostty"
msgstr ""
#: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:197

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: com.mitchellh.ghostty\n"
"Report-Msgid-Bugs-To: m@mitchellh.com\n"
"POT-Creation-Date: 2026-02-16 23:06+0100\n"
"POT-Creation-Date: 2026-02-17 23:16+0100\n"
"PO-Revision-Date: 2026-02-10 15:32+0800\n"
"Last-Translator: Yi-Jyun Pan <me@pan93.com>\n"
"Language-Team: Chinese (traditional)\n"
@@ -16,6 +16,10 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: dist/linux/ghostty_nautilus.py:53
msgid "Open in Ghostty"
msgstr ""
#: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12
#: src/apprt/gtk/class/clipboard_confirmation_dialog.zig:197

View File

@@ -18,6 +18,7 @@ const Command = @This();
const std = @import("std");
const builtin = @import("builtin");
const configpkg = @import("config.zig");
const global_state = &@import("global.zig").state;
const internal_os = @import("os/main.zig");
const windows = internal_os.windows;
@@ -30,8 +31,20 @@ const testing = std.testing;
const Allocator = std.mem.Allocator;
const File = std.fs.File;
const EnvMap = std.process.EnvMap;
const apprt = @import("apprt.zig");
const PreExecFn = fn (*Command) void;
/// Function prototype for a function executed /in the child process/ after the
/// fork, but before exec'ing the command. If the function returns a u8, the
/// child process will be exited with that error code.
const PreExecFn = fn (*Command) ?u8;
/// Allowable set of errors that can be returned by a post fork function. Any
/// errors will result in the failure to create the surface.
pub const PostForkError = error{PostForkError};
/// Function prototype for a function executed /in the parent process/
/// after the fork.
const PostForkFn = fn (*Command) PostForkError!void;
/// Path to the command to run. This doesn't have to be an absolute path,
/// because use exec functions that search the PATH, if necessary.
@@ -63,9 +76,25 @@ stderr: ?File = null,
/// If set, this will be executed /in the child process/ after fork but
/// before exec. This is useful to setup some state in the child before the
/// exec process takes over, such as signal handlers, setsid, setuid, etc.
pre_exec: ?*const PreExecFn = null,
os_pre_exec: ?*const PreExecFn,
linux_cgroup: LinuxCgroup = linux_cgroup_default,
/// If set, this will be executed /in the child process/ after fork but
/// before exec. This is useful to setup some state in the child before the
/// exec process takes over, such as signal handlers, setsid, setuid, etc.
rt_pre_exec: ?*const PreExecFn,
/// Configuration information needed by the apprt pre exec function. Note
/// that this should be a trivially copyable struct and not require any
/// allocation/deallocation.
rt_pre_exec_info: RtPreExecInfo,
/// If set, this will be executed in the /in the parent process/ after the fork.
rt_post_fork: ?*const PostForkFn,
/// Configuration information needed by the apprt post fork function. Note
/// that this should be a trivially copyable struct and not require any
/// allocation/deallocation.
rt_post_fork_info: RtPostForkInfo,
/// If set, then the process will be created attached to this pseudo console.
/// `stdin`, `stdout`, and `stderr` will be ignored if set.
@@ -79,11 +108,6 @@ data: ?*anyopaque = null,
/// Process ID is set after start is called.
pid: ?posix.pid_t = null,
/// LinuxCGroup type depends on our target OS
pub const LinuxCgroup = if (builtin.os.tag == .linux) ?[]const u8 else void;
pub const linux_cgroup_default = if (LinuxCgroup == void)
{} else null;
/// The various methods a process may exit.
pub const Exit = if (builtin.os.tag == .windows) union(enum) {
Exited: u32,
@@ -112,6 +136,24 @@ pub const Exit = if (builtin.os.tag == .windows) union(enum) {
}
};
/// Configuration information needed by the apprt pre exec function. Note
/// that this should be a trivially copyable struct and not require any
/// allocation/deallocation.
pub const RtPreExecInfo = if (@hasDecl(apprt.runtime, "pre_exec")) apprt.runtime.pre_exec.PreExecInfo else struct {
pub inline fn init(_: *const configpkg.Config) @This() {
return .{};
}
};
/// Configuration information needed by the apprt post fork function. Note
/// that this should be a trivially copyable struct and not require any
/// allocation/deallocation.
pub const RtPostForkInfo = if (@hasDecl(apprt.runtime, "post_fork")) apprt.runtime.post_fork.PostForkInfo else struct {
pub inline fn init(_: *const configpkg.Config) @This() {
return .{};
}
};
/// Start the subprocess. This returns immediately once the child is started.
///
/// After this is successful, self.pid is available.
@@ -143,19 +185,13 @@ fn startPosix(self: *Command, arena: Allocator) !void {
else
@compileError("missing env vars");
// Fork. If we have a cgroup specified on Linxu then we use clone
const pid: posix.pid_t = switch (builtin.os.tag) {
.linux => if (self.linux_cgroup) |cgroup|
try internal_os.cgroup.cloneInto(cgroup)
else
try posix.fork(),
else => try posix.fork(),
};
// Fork.
const pid = try posix.fork();
if (pid != 0) {
// Parent, return immediately.
self.pid = @intCast(pid);
if (self.rt_post_fork) |f| try f(self);
return;
}
@@ -182,8 +218,9 @@ fn startPosix(self: *Command, arena: Allocator) !void {
// any failures are ignored (its best effort).
global_state.rlimits.restore();
// If the user requested a pre exec callback, call it now.
if (self.pre_exec) |f| f(self);
// If there are pre exec callbacks, call them now.
if (self.os_pre_exec) |f| if (f(self)) |exitcode| posix.exit(exitcode);
if (self.rt_pre_exec) |f| if (f(self)) |exitcode| posix.exit(exitcode);
// Finally, replace our process.
// Note: we must use the "p"-variant of exec here because we
@@ -533,18 +570,22 @@ test "createNullDelimitedEnvMap" {
}
}
test "Command: pre exec" {
test "Command: os pre exec 1" {
if (builtin.os.tag == .windows) return error.SkipZigTest;
var cmd: Command = .{
.path = "/bin/sh",
.args = &.{ "/bin/sh", "-v" },
.pre_exec = (struct {
fn do(_: *Command) void {
.os_pre_exec = (struct {
fn do(_: *Command) ?u8 {
// This runs in the child, so we can exit and it won't
// kill the test runner.
posix.exit(42);
}
}).do,
.rt_pre_exec = null,
.rt_post_fork = null,
.rt_pre_exec_info = undefined,
.rt_post_fork_info = undefined,
};
try cmd.testingStart();
@@ -554,6 +595,100 @@ test "Command: pre exec" {
try testing.expect(exit.Exited == 42);
}
test "Command: os pre exec 2" {
if (builtin.os.tag == .windows) return error.SkipZigTest;
var cmd: Command = .{
.path = "/bin/sh",
.args = &.{ "/bin/sh", "-v" },
.os_pre_exec = (struct {
fn do(_: *Command) ?u8 {
// This runs in the child, so we can exit and it won't
// kill the test runner.
return 42;
}
}).do,
.rt_pre_exec = null,
.rt_post_fork = null,
.rt_pre_exec_info = undefined,
.rt_post_fork_info = undefined,
};
try cmd.testingStart();
try testing.expect(cmd.pid != null);
const exit = try cmd.wait(true);
try testing.expect(exit == .Exited);
try testing.expect(exit.Exited == 42);
}
test "Command: rt pre exec 1" {
if (builtin.os.tag == .windows) return error.SkipZigTest;
var cmd: Command = .{
.path = "/bin/sh",
.args = &.{ "/bin/sh", "-v" },
.os_pre_exec = null,
.rt_pre_exec = (struct {
fn do(_: *Command) ?u8 {
// This runs in the child, so we can exit and it won't
// kill the test runner.
posix.exit(42);
}
}).do,
.rt_post_fork = null,
.rt_pre_exec_info = undefined,
.rt_post_fork_info = undefined,
};
try cmd.testingStart();
try testing.expect(cmd.pid != null);
const exit = try cmd.wait(true);
try testing.expect(exit == .Exited);
try testing.expect(exit.Exited == 42);
}
test "Command: rt pre exec 2" {
if (builtin.os.tag == .windows) return error.SkipZigTest;
var cmd: Command = .{
.path = "/bin/sh",
.args = &.{ "/bin/sh", "-v" },
.os_pre_exec = null,
.rt_pre_exec = (struct {
fn do(_: *Command) ?u8 {
// This runs in the child, so we can exit and it won't
// kill the test runner.
return 42;
}
}).do,
.rt_post_fork = null,
.rt_pre_exec_info = undefined,
.rt_post_fork_info = undefined,
};
try cmd.testingStart();
try testing.expect(cmd.pid != null);
const exit = try cmd.wait(true);
try testing.expect(exit == .Exited);
try testing.expect(exit.Exited == 42);
}
test "Command: rt post fork 1" {
if (builtin.os.tag == .windows) return error.SkipZigTest;
var cmd: Command = .{
.path = "/bin/sh",
.args = &.{ "/bin/sh", "-c", "sleep 1" },
.os_pre_exec = null,
.rt_pre_exec = null,
.rt_post_fork = (struct {
fn do(_: *Command) PostForkError!void {
return error.PostForkError;
}
}).do,
.rt_pre_exec_info = undefined,
.rt_post_fork_info = undefined,
};
try testing.expectError(error.PostForkError, cmd.testingStart());
}
fn createTestStdout(dir: std.fs.Dir) !File {
const file = try dir.createFile("stdout.txt", .{ .read = true });
if (builtin.os.tag == .windows) {
@@ -567,6 +702,19 @@ fn createTestStdout(dir: std.fs.Dir) !File {
return file;
}
fn createTestStderr(dir: std.fs.Dir) !File {
const file = try dir.createFile("stderr.txt", .{ .read = true });
if (builtin.os.tag == .windows) {
try windows.SetHandleInformation(
file.handle,
windows.HANDLE_FLAG_INHERIT,
windows.HANDLE_FLAG_INHERIT,
);
}
return file;
}
test "Command: redirect stdout to file" {
var td = try TempDir.init();
defer td.deinit();
@@ -581,6 +729,11 @@ test "Command: redirect stdout to file" {
.path = "/bin/sh",
.args = &.{ "/bin/sh", "-c", "echo hello" },
.stdout = stdout,
.os_pre_exec = null,
.rt_pre_exec = null,
.rt_post_fork = null,
.rt_pre_exec_info = undefined,
.rt_post_fork_info = undefined,
};
try cmd.testingStart();
@@ -611,11 +764,21 @@ test "Command: custom env vars" {
.args = &.{ "C:\\Windows\\System32\\cmd.exe", "/C", "echo %VALUE%" },
.stdout = stdout,
.env = &env,
.os_pre_exec = null,
.rt_pre_exec = null,
.rt_post_fork = null,
.rt_pre_exec_info = undefined,
.rt_post_fork_info = undefined,
} else .{
.path = "/bin/sh",
.args = &.{ "/bin/sh", "-c", "echo $VALUE" },
.stdout = stdout,
.env = &env,
.os_pre_exec = null,
.rt_pre_exec = null,
.rt_post_fork = null,
.rt_pre_exec_info = undefined,
.rt_post_fork_info = undefined,
};
try cmd.testingStart();
@@ -647,11 +810,21 @@ test "Command: custom working directory" {
.args = &.{ "C:\\Windows\\System32\\cmd.exe", "/C", "cd" },
.stdout = stdout,
.cwd = "C:\\Windows\\System32",
.os_pre_exec = null,
.rt_pre_exec = null,
.rt_post_fork = null,
.rt_pre_exec_info = undefined,
.rt_post_fork_info = undefined,
} else .{
.path = "/bin/sh",
.args = &.{ "/bin/sh", "-c", "pwd" },
.stdout = stdout,
.cwd = "/tmp",
.os_pre_exec = null,
.rt_pre_exec = null,
.rt_post_fork = null,
.rt_pre_exec_info = undefined,
.rt_post_fork_info = undefined,
};
try cmd.testingStart();
@@ -688,12 +861,20 @@ test "Command: posix fork handles execveZ failure" {
defer td.deinit();
var stdout = try createTestStdout(td.dir);
defer stdout.close();
var stderr = try createTestStderr(td.dir);
defer stderr.close();
var cmd: Command = .{
.path = "/not/a/binary",
.args = &.{ "/not/a/binary", "" },
.stdout = stdout,
.stderr = stderr,
.cwd = "/bin",
.os_pre_exec = null,
.rt_pre_exec = null,
.rt_post_fork = null,
.rt_pre_exec_info = undefined,
.rt_post_fork_info = undefined,
};
try cmd.testingStart();

View File

@@ -636,16 +636,8 @@ pub fn init(
.working_directory = config.@"working-directory",
.resources_dir = global_state.resources_dir.host(),
.term = config.term,
// Get the cgroup if we're on linux and have the decl. I'd love
// to change this from a decl to a surface options struct because
// then we can do memory management better (don't need to retain
// the string around).
.linux_cgroup = if (comptime builtin.os.tag == .linux and
@hasDecl(apprt.runtime.Surface, "cgroup"))
rt_surface.cgroup()
else
Command.linux_cgroup_default,
.rt_pre_exec_info = .init(config),
.rt_post_fork_info = .init(config),
});
errdefer io_exec.deinit();

View File

@@ -6,6 +6,8 @@ pub const resourcesDir = @import("gtk/flatpak.zig").resourcesDir;
// The exported API, custom for the apprt.
pub const class = @import("gtk/class.zig");
pub const WeakRef = @import("gtk/weak_ref.zig").WeakRef;
pub const pre_exec = @import("gtk/pre_exec.zig");
pub const post_fork = @import("gtk/post_fork.zig");
test {
@import("std").testing.refAllDecls(@This());

View File

@@ -1,7 +1,8 @@
/// Contains all the logic for putting the Ghostty process and
/// each individual surface into its own cgroup.
/// Contains all the logic for putting individual surfaces into
/// transient systemd scopes.
const std = @import("std");
const Allocator = std.mem.Allocator;
const assert = @import("../../quirks.zig").inlineAssert;
const gio = @import("gio");
const glib = @import("glib");
@@ -12,125 +13,27 @@ const log = std.log.scoped(.gtk_systemd_cgroup);
pub const Options = struct {
memory_high: ?u64 = null,
pids_max: ?u64 = null,
tasks_max: ?u64 = null,
};
/// Initialize the cgroup for the app. This will create our
/// transient scope, initialize the cgroups we use for the app,
/// configure them, and return the cgroup path for the app.
///
/// Returns the path of the current cgroup for the app, which is
/// allocated with the given allocator.
pub fn init(
alloc: Allocator,
dbus: *gio.DBusConnection,
opts: Options,
) ![]const u8 {
const pid = std.os.linux.getpid();
pub fn fmtScope(buf: []u8, pid: u32) [:0]const u8 {
const fmt = "app-ghostty-surface-transient-{}.scope";
// Get our initial cgroup. We need this so we can compare
// and detect when we've switched to our transient group.
const original = try internal_os.cgroup.current(
alloc,
pid,
) orelse "";
defer alloc.free(original);
assert(buf.len >= fmt.len - 2 + std.math.log10_int(@as(usize, std.math.maxInt(@TypeOf(pid)))) + 1);
// Create our transient scope. If this succeeds then the unit
// was created, but we may not have moved into it yet, so we need
// to do a dumb busy loop to wait for the move to complete.
try createScope(dbus, pid);
const transient = transient: while (true) {
const current = try internal_os.cgroup.current(
alloc,
pid,
) orelse "";
if (!std.mem.eql(u8, original, current)) break :transient current;
alloc.free(current);
std.Thread.sleep(25 * std.time.ns_per_ms);
};
errdefer alloc.free(transient);
log.info("transient scope created cgroup={s}", .{transient});
// Create the app cgroup and put ourselves in it. This is
// required because controllers can't be configured while a
// process is in a cgroup.
try internal_os.cgroup.create(transient, "app", pid);
// Create a cgroup that will contain all our surfaces. We will
// enable the controllers and configure resource limits for surfaces
// only on this cgroup so that it doesn't affect our main app.
try internal_os.cgroup.create(transient, "surfaces", null);
const surfaces = try std.fmt.allocPrint(alloc, "{s}/surfaces", .{transient});
defer alloc.free(surfaces);
// Enable all of our cgroup controllers. If these fail then
// we just log. We can't reasonably undo what we've done above
// so we log the warning and still return the transient group.
// I don't know a scenario where this fails yet.
try enableControllers(alloc, transient);
try enableControllers(alloc, surfaces);
// Configure the "high" memory limit. This limit is used instead
// of "max" because it's a soft limit that can be exceeded and
// can be monitored by things like systemd-oomd to kill if needed,
// versus an instant hard kill.
if (opts.memory_high) |limit| {
try internal_os.cgroup.configureLimit(surfaces, .{
.memory_high = limit,
});
}
// Configure the "max" pids limit. This is a hard limit and cannot be
// exceeded.
if (opts.pids_max) |limit| {
try internal_os.cgroup.configureLimit(surfaces, .{
.pids_max = limit,
});
}
return transient;
return std.fmt.bufPrintZ(buf, fmt, .{pid}) catch unreachable;
}
/// Enable all the cgroup controllers for the given cgroup.
fn enableControllers(alloc: Allocator, cgroup: []const u8) !void {
const raw = try internal_os.cgroup.controllers(alloc, cgroup);
defer alloc.free(raw);
// Build our string builder for enabling all controllers
var builder: std.Io.Writer.Allocating = .init(alloc);
defer builder.deinit();
// Controllers are space-separated
var it = std.mem.splitScalar(u8, raw, ' ');
while (it.next()) |controller| {
try builder.writer.writeByte('+');
try builder.writer.writeAll(controller);
if (it.rest().len > 0) try builder.writer.writeByte(' ');
}
// Enable them all
try internal_os.cgroup.configureControllers(
cgroup,
builder.written(),
);
}
/// Create a transient systemd scope unit for the current process and
/// move our process into it.
fn createScope(
/// Create a transient systemd scope unit for the given process and
/// move the process into it.
pub fn createScope(
dbus: *gio.DBusConnection,
pid_: std.os.linux.pid_t,
) !void {
const pid: u32 = @intCast(pid_);
// The unit name needs to be unique. We use the pid for this.
pid: u32,
options: Options,
) error{DbusCallFailed}!void {
// The unit name needs to be unique. We use the PID for this.
var name_buf: [256]u8 = undefined;
const name = std.fmt.bufPrintZ(
&name_buf,
"app-ghostty-transient-{}.scope",
.{pid},
) catch unreachable;
const name = fmtScope(&name_buf, pid);
const builder_type = glib.VariantType.new("(ssa(sv)a(sa(sv)))");
defer glib.free(builder_type);
@@ -150,16 +53,18 @@ fn createScope(
builder.open(properties_type);
defer builder.close();
if (options.memory_high) |value| {
builder.add("(sv)", "MemoryHigh", glib.Variant.newUint64(value));
}
if (options.tasks_max) |value| {
builder.add("(sv)", "TasksMax", glib.Variant.newUint64(value));
}
// https://www.freedesktop.org/software/systemd/man/latest/systemd-oomd.service.html
const pressure_value = glib.Variant.newString("kill");
builder.add("(sv)", "ManagedOOMMemoryPressure", glib.Variant.newString("kill"));
builder.add("(sv)", "ManagedOOMMemoryPressure", pressure_value);
// Delegate
const delegate_value = glib.Variant.newBoolean(1);
builder.add("(sv)", "Delegate", delegate_value);
// Pid to move into the unit
// PID to move into the unit
const pids_value_type = glib.VariantType.new("u");
defer glib.free(pids_value_type);
@@ -169,7 +74,7 @@ fn createScope(
}
{
// Aux
// Aux - unused but must be present
const aux_type = glib.VariantType.new("a(sa(sv))");
defer glib.free(aux_type);

View File

@@ -12,7 +12,6 @@ const build_config = @import("../../../build_config.zig");
const state = &@import("../../../global.zig").state;
const i18n = @import("../../../os/main.zig").i18n;
const apprt = @import("../../../apprt.zig");
const cgroup = @import("../cgroup.zig");
const CoreApp = @import("../../../App.zig");
const configpkg = @import("../../../config.zig");
const input = @import("../../../input.zig");
@@ -176,11 +175,6 @@ pub const Application = extern struct {
/// The global shortcut logic.
global_shortcuts: *GlobalShortcuts,
/// The base path of the transient cgroup used to put all surfaces
/// into their own cgroup. This is only set if cgroups are enabled
/// and initialization was successful.
transient_cgroup_base: ?[]const u8 = null,
/// This is set to true so long as we request a window exactly
/// once. This prevents quitting the app before we've shown one
/// window.
@@ -438,7 +432,6 @@ pub const Application = extern struct {
priv.config.unref();
priv.winproto.deinit(alloc);
priv.global_shortcuts.unref();
if (priv.transient_cgroup_base) |base| alloc.free(base);
if (priv.saved_language) |language| alloc.free(language);
if (gdk.Display.getDefault()) |display| {
gtk.StyleContext.removeProviderForDisplay(
@@ -809,11 +802,6 @@ pub const Application = extern struct {
return &self.private().winproto;
}
/// Returns the cgroup base (if any).
pub fn cgroupBase(self: *Self) ?[]const u8 {
return self.private().transient_cgroup_base;
}
/// This will get called when there are no more open surfaces.
fn startQuitTimer(self: *Self) void {
const priv = self.private();
@@ -1312,22 +1300,6 @@ pub const Application = extern struct {
// Setup our global shortcuts
self.startupGlobalShortcuts();
// Setup our cgroup for the application.
self.startupCgroup() catch |err| {
log.warn("cgroup initialization failed err={}", .{err});
// Add it to our config diagnostics so it shows up in a GUI dialog.
// Admittedly this has two issues: (1) we shuldn't be using the
// config errors dialog for this long term and (2) using a mut
// ref to the config wouldn't propagate changes to UI properly,
// but we're in startup mode so its okay.
const config = self.private().config.getMut();
config.addDiagnosticFmt(
"cgroup initialization failed: {}",
.{err},
) catch {};
};
// If we have any config diagnostics from loading, then we
// show the diagnostics dialog. We show this one as a general
// modal (not to any specific window) because we don't even
@@ -1461,72 +1433,6 @@ pub const Application = extern struct {
);
}
const CgroupError = error{
DbusConnectionFailed,
CgroupInitFailed,
};
/// Setup our cgroup for the application, if enabled.
///
/// The setup for cgroups involves creating the cgroup for our
/// application, moving ourselves into it, and storing the base path
/// so that created surfaces can also have their own cgroups.
fn startupCgroup(self: *Self) CgroupError!void {
const priv = self.private();
const config = priv.config.get();
// If cgroup isolation isn't enabled then we don't do this.
if (!switch (config.@"linux-cgroup") {
.never => false,
.always => true,
.@"single-instance" => single: {
const flags = self.as(gio.Application).getFlags();
break :single !flags.non_unique;
},
}) {
log.info(
"cgroup isolation disabled via config={}",
.{config.@"linux-cgroup"},
);
return;
}
// We need a dbus connection to do anything else
const dbus = self.as(gio.Application).getDbusConnection() orelse {
if (config.@"linux-cgroup-hard-fail") {
log.err("dbus connection required for cgroup isolation, exiting", .{});
return error.DbusConnectionFailed;
}
return;
};
const alloc = priv.core_app.alloc;
const path = cgroup.init(alloc, dbus, .{
.memory_high = config.@"linux-cgroup-memory-limit",
.pids_max = config.@"linux-cgroup-processes-limit",
}) catch |err| {
// If we can't initialize cgroups then that's okay. We
// want to continue to run so we just won't isolate surfaces.
// NOTE(mitchellh): do we want a config to force it?
log.warn(
"failed to initialize cgroups, terminals will not be isolated err={}",
.{err},
);
// If we have hard fail enabled then we exit now.
if (config.@"linux-cgroup-hard-fail") {
log.err("linux-cgroup-hard-fail enabled, exiting", .{});
return error.CgroupInitFailed;
}
return;
};
log.info("cgroup isolation enabled base={s}", .{path});
priv.transient_cgroup_base = path;
}
fn activate(self: *Self) callconv(.c) void {
log.debug("activate", .{});

View File

@@ -551,10 +551,6 @@ pub const Surface = extern struct {
/// The configuration that this surface is using.
config: ?*Config = null,
/// The cgroup created for this surface. This will be created
/// if `Application.transient_cgroup_base` is set.
cgroup_path: ?[]const u8 = null,
/// The default size for a window that embeds this surface.
default_size: ?*Size = null,
@@ -1433,63 +1429,6 @@ pub const Surface = extern struct {
};
}
/// Initialize the cgroup for this surface if it hasn't been
/// already. While this is `init`-prefixed, we prefer to call this
/// in the realize function because we don't need to create a cgroup
/// if we don't init a surface.
fn initCgroup(self: *Self) void {
const priv = self.private();
// If we already have a cgroup path then we don't do it again.
if (priv.cgroup_path != null) return;
const app = Application.default();
const alloc = app.allocator();
const base = app.cgroupBase() orelse return;
// For the unique group name we use the self pointer. This may
// not be a good idea for security reasons but not sure yet. We
// may want to change this to something else eventually to be safe.
var buf: [256]u8 = undefined;
const name = std.fmt.bufPrint(
&buf,
"surfaces/{X}.scope",
.{@intFromPtr(self)},
) catch unreachable;
// Create the cgroup. If it fails, no big deal... just ignore.
internal_os.cgroup.create(base, name, null) catch |err| {
log.warn("failed to create surface cgroup err={}", .{err});
return;
};
// Success, save the cgroup path.
priv.cgroup_path = std.fmt.allocPrint(
alloc,
"{s}/{s}",
.{ base, name },
) catch null;
}
/// Deletes the cgroup if set.
fn clearCgroup(self: *Self) void {
const priv = self.private();
const path = priv.cgroup_path orelse return;
internal_os.cgroup.remove(path) catch |err| {
// We don't want this to be fatal in any way so we just log
// and continue. A dangling empty cgroup is not a big deal
// and this should be rare.
log.warn(
"failed to remove cgroup for surface path={s} err={}",
.{ path, err },
);
};
Application.default().allocator().free(path);
priv.cgroup_path = null;
}
//---------------------------------------------------------------
// Libghostty Callbacks
@@ -1525,10 +1464,6 @@ pub const Surface = extern struct {
return true;
}
pub fn cgroupPath(self: *Self) ?[]const u8 {
return self.private().cgroup_path;
}
pub fn getContentScale(self: *Self) apprt.ContentScale {
const priv = self.private();
const gl_area = priv.gl_area;
@@ -1968,8 +1903,6 @@ pub const Surface = extern struct {
for (priv.key_tables.items) |s| alloc.free(s);
priv.key_tables.deinit(alloc);
self.clearCgroup();
gobject.Object.virtual_methods.finalize.call(
Class.parent,
self.as(Parent),
@@ -3331,10 +3264,6 @@ pub const Surface = extern struct {
const app = Application.default();
const alloc = app.allocator();
// Initialize our cgroup if we can.
self.initCgroup();
errdefer self.clearCgroup();
// Make our pointer to store our surface
const surface = try alloc.create(CoreSurface);
errdefer alloc.destroy(surface);

121
src/apprt/gtk/post_fork.zig Normal file
View File

@@ -0,0 +1,121 @@
const std = @import("std");
const gio = @import("gio");
const glib = @import("glib");
const log = std.log.scoped(.gtk_post_fork);
const configpkg = @import("../../config.zig");
const internal_os = @import("../../os/main.zig");
const Command = @import("../../Command.zig");
const cgroup = @import("./cgroup.zig");
const Application = @import("class/application.zig").Application;
pub const PostForkInfo = struct {
gtk_single_instance: configpkg.Config.GtkSingleInstance,
linux_cgroup: configpkg.Config.LinuxCgroup,
linux_cgroup_hard_fail: bool,
linux_cgroup_memory_limit: ?u64,
linux_cgroup_processes_limit: ?u64,
pub fn init(cfg: *const configpkg.Config) PostForkInfo {
return .{
.gtk_single_instance = cfg.@"gtk-single-instance",
.linux_cgroup = cfg.@"linux-cgroup",
.linux_cgroup_hard_fail = cfg.@"linux-cgroup-hard-fail",
.linux_cgroup_memory_limit = cfg.@"linux-cgroup-memory-limit",
.linux_cgroup_processes_limit = cfg.@"linux-cgroup-processes-limit",
};
}
};
/// If we are configured to do so, tell `systemd` to move the new child PID into
/// a transient `systemd` scope with the configured resource limits.
///
/// If we are configured to hard fail, log an error message and return an error
/// code if we don't detect the move in time.
pub fn postFork(cmd: *Command) Command.PostForkError!void {
switch (cmd.rt_post_fork_info.linux_cgroup) {
.always => {},
.never => return,
.@"single-instance" => switch (cmd.rt_post_fork_info.gtk_single_instance) {
.true => {},
.false => return,
.detect => {
log.err("gtk-single-instance is set to detect which should be impossible!", .{});
return error.PostForkError;
},
},
}
const pid: u32 = @intCast(cmd.pid orelse {
log.err("PID of child not known!", .{});
return error.PostForkError;
});
var expected_cgroup_buf: [256]u8 = undefined;
const expected_cgroup = cgroup.fmtScope(&expected_cgroup_buf, pid);
log.debug("beginning transition to transient systemd scope {s}", .{expected_cgroup});
const app = Application.default();
const dbus = app.as(gio.Application).getDbusConnection() orelse {
if (cmd.rt_post_fork_info.linux_cgroup_hard_fail) {
log.err("dbus connection required for cgroup isolation, exiting", .{});
return error.PostForkError;
}
return;
};
cgroup.createScope(
dbus,
pid,
.{
.memory_high = cmd.rt_post_fork_info.linux_cgroup_memory_limit,
.tasks_max = cmd.rt_post_fork_info.linux_cgroup_processes_limit,
},
) catch |err| {
if (cmd.rt_post_fork_info.linux_cgroup_hard_fail) {
log.err("unable to create transient systemd scope {s}: {t}", .{ expected_cgroup, err });
return error.PostForkError;
}
log.warn("unable to create transient systemd scope {s}: {t}", .{ expected_cgroup, err });
return;
};
const start = std.time.Instant.now() catch unreachable;
loop: while (true) {
const now = std.time.Instant.now() catch unreachable;
if (now.since(start) > 250 * std.time.ns_per_ms) {
if (cmd.rt_pre_exec_info.linux_cgroup_hard_fail) {
log.err("transition to new transient systemd scope {s} took too long", .{expected_cgroup});
return error.PostForkError;
}
log.warn("transition to transient systemd scope {s} took too long", .{expected_cgroup});
break :loop;
}
not_found: {
var current_cgroup_buf: [4096]u8 = undefined;
const current_cgroup_raw = internal_os.cgroup.current(
&current_cgroup_buf,
@intCast(pid),
) orelse break :not_found;
const index = std.mem.lastIndexOfScalar(u8, current_cgroup_raw, '/') orelse break :not_found;
const current_cgroup = current_cgroup_raw[index + 1 ..];
if (std.mem.eql(u8, current_cgroup, expected_cgroup)) {
log.debug("transition to transient systemd scope {s} complete", .{expected_cgroup});
break :loop;
}
}
std.Thread.sleep(25 * std.time.ns_per_ms);
}
}

View File

@@ -0,0 +1,81 @@
const std = @import("std");
const log = std.log.scoped(.gtk_pre_exec);
const configpkg = @import("../../config.zig");
const internal_os = @import("../../os/main.zig");
const Command = @import("../../Command.zig");
const cgroup = @import("./cgroup.zig");
pub const PreExecInfo = struct {
gtk_single_instance: configpkg.Config.GtkSingleInstance,
linux_cgroup: configpkg.Config.LinuxCgroup,
linux_cgroup_hard_fail: bool,
pub fn init(cfg: *const configpkg.Config) PreExecInfo {
return .{
.gtk_single_instance = cfg.@"gtk-single-instance",
.linux_cgroup = cfg.@"linux-cgroup",
.linux_cgroup_hard_fail = cfg.@"linux-cgroup-hard-fail",
};
}
};
/// If we are expecting to be moved to a transient systemd scope, wait to see if
/// that happens by checking for the correct name of the current cgroup. Wait at
/// most 250ms so that we don't overly delay the soft-fail scenario.
///
/// If we are configured to hard fail, log an error message and return an error
/// code if we don't detect the move in time.
pub fn preExec(cmd: *Command) ?u8 {
switch (cmd.rt_pre_exec_info.linux_cgroup) {
.always => {},
.never => return null,
.@"single-instance" => switch (cmd.rt_pre_exec_info.gtk_single_instance) {
.true => {},
.false => return null,
.detect => {
log.err("gtk-single-instance is set to detect", .{});
return 127;
},
},
}
const pid: u32 = @intCast(std.os.linux.getpid());
var expected_cgroup_buf: [256]u8 = undefined;
const expected_cgroup = cgroup.fmtScope(&expected_cgroup_buf, pid);
const start = std.time.Instant.now() catch unreachable;
while (true) {
const now = std.time.Instant.now() catch unreachable;
if (now.since(start) > 250 * std.time.ns_per_ms) {
if (cmd.rt_pre_exec_info.linux_cgroup_hard_fail) {
log.err("transition to new transient systemd scope took too long", .{});
return 127;
}
break;
}
not_found: {
var current_cgroup_buf: [4096]u8 = undefined;
const current_cgroup_raw = internal_os.cgroup.current(
&current_cgroup_buf,
@intCast(pid),
) orelse break :not_found;
const index = std.mem.lastIndexOfScalar(u8, current_cgroup_raw, '/') orelse break :not_found;
const current_cgroup = current_cgroup_raw[index + 1 ..];
if (std.mem.eql(u8, current_cgroup, expected_cgroup)) return null;
}
std.Thread.sleep(25 * std.time.ns_per_ms);
}
return null;
}

View File

@@ -65,16 +65,14 @@ fn createUpdateStep(b: *std.Build) !*std.Build.Step {
"xgettext",
"--language=C", // Silence the "unknown extension" errors
"--from-code=UTF-8",
"--add-comments=Translators",
"--keyword=_",
"--keyword=C_:1c,2",
"--package-name=" ++ domain,
"--msgid-bugs-address=m@mitchellh.com",
"--copyright-holder=\"Mitchell Hashimoto, Ghostty contributors\"",
"-o",
"-",
});
// Collect to intermediate .pot file
xgettext.addArg("-o");
const gtk_pot = xgettext.addOutputFileArg("gtk.pot");
// Not cacheable due to the gresource files
xgettext.has_side_effects = true;
@@ -149,16 +147,45 @@ fn createUpdateStep(b: *std.Build) !*std.Build.Step {
}
}
// Add support for localizing our `nautilus` integration
const xgettext_py = b.addSystemCommand(&.{
"xgettext",
"--language=Python",
"--from-code=UTF-8",
});
// Collect to intermediate .pot file
xgettext_py.addArg("-o");
const py_pot = xgettext_py.addOutputFileArg("py.pot");
const nautilus_script_path = "dist/linux/ghostty_nautilus.py";
xgettext_py.addArg(nautilus_script_path);
xgettext_py.addFileInput(b.path(nautilus_script_path));
// Merge pot files
const xgettext_merge = b.addSystemCommand(&.{
"xgettext",
"--add-comments=Translators",
"--package-name=" ++ domain,
"--msgid-bugs-address=m@mitchellh.com",
"--copyright-holder=\"Mitchell Hashimoto, Ghostty contributors\"",
"-o",
"-",
});
// py_pot needs to be first on merge order because of `xgettext` behavior around
// charset when merging the two `.pot` files
xgettext_merge.addFileArg(py_pot);
xgettext_merge.addFileArg(gtk_pot);
const usf = b.addUpdateSourceFiles();
usf.addCopyFileToSource(
xgettext.captureStdOut(),
xgettext_merge.captureStdOut(),
"po/" ++ domain ++ ".pot",
);
inline for (locales) |locale| {
const msgmerge = b.addSystemCommand(&.{ "msgmerge", "--quiet", "--no-fuzzy-matching" });
msgmerge.addFileArg(b.path("po/" ++ locale ++ ".po"));
msgmerge.addFileArg(xgettext.captureStdOut());
msgmerge.addFileArg(xgettext_merge.captureStdOut());
usf.addCopyFileToSource(msgmerge.captureStdOut(), "po/" ++ locale ++ ".po");
}

View File

@@ -784,8 +784,30 @@ foreground: Color = .{ .r = 0xFF, .g = 0xFF, .b = 0xFF },
///
/// For definitions on the color indices and what they canonically map to,
/// [see this cheat sheet](https://www.ditig.com/256-colors-cheat-sheet).
///
/// For most themes, you only need to set the first 16 colors (015) since the
/// rest of the palette (16255) will be automatically generated by
/// default (see `palette-generate` for more details).
palette: Palette = .{},
/// Whether to automatically generate the extended 256 color palette
/// (indices 16255) from the base 16 ANSI colors.
///
/// This lets theme authors specify only the base 16 colors and have the
/// rest of the palette be automatically generated in a consistent and
/// aesthetic way.
///
/// When enabled, the 6×6×6 color cube and 24-step grayscale ramp are
/// derived from interpolations of the base palette, giving a more cohesive
/// look. Colors that have been explicitly set via `palette` are never
/// overwritten.
///
/// For more information on how the generation works, see here:
/// https://gist.github.com/jake-stewart/0a8ea46159a7da2c808e5be2177e1783
///
/// Available since: 1.3.0
@"palette-generate": bool = true,
/// The color of the cursor. If this is not set, a default will be chosen.
///
/// Direct colors can be specified as either hex (`#RRGGBB` or `RRGGBB`)
@@ -3370,13 +3392,12 @@ keybind: Keybinds = .{},
/// Available since: 1.2.0
@"macos-shortcuts": MacShortcuts = .ask,
/// Put every surface (tab, split, window) into a dedicated Linux cgroup.
/// Put every surface (tab, split, window) into a transient `systemd` scope.
///
/// This makes it so that resource management can be done on a per-surface
/// granularity. For example, if a shell program is using too much memory,
/// only that shell will be killed by the oom monitor instead of the entire
/// Ghostty process. Similarly, if a shell program is using too much CPU,
/// only that surface will be CPU-throttled.
/// This allows per-surface resource management. For example, if a shell program
/// is using too much memory, only that shell will be killed by the oom monitor
/// instead of the entire Ghostty process. Similarly, if a shell program is
/// using too much CPU, only that surface will be CPU-throttled.
///
/// This will cause startup times to be slower (a hundred milliseconds or so),
/// so the default value is "single-instance." In single-instance mode, only
@@ -3385,9 +3406,12 @@ keybind: Keybinds = .{},
/// more likely to have many windows, tabs, etc. so cgroup isolation is a
/// big benefit.
///
/// This feature requires systemd. If systemd is unavailable, cgroup
/// initialization will fail. By default, this will not prevent Ghostty
/// from working (see linux-cgroup-hard-fail).
/// This feature requires `systemd`. If `systemd` is unavailable, cgroup
/// initialization will fail. By default, this will not prevent Ghostty from
/// working (see `linux-cgroup-hard-fail`).
///
/// Changing this value and reloading the config will not affect existing
/// surfaces.
///
/// Valid values are:
///
@@ -3403,30 +3427,42 @@ else
/// Memory limit for any individual terminal process (tab, split, window,
/// etc.) in bytes. If this is unset then no memory limit will be set.
///
/// Note that this sets the "memory.high" configuration for the memory
/// controller, which is a soft limit. You should configure something like
/// systemd-oom to handle killing processes that have too much memory
/// Note that this sets the `MemoryHigh` setting on the transient `systemd`
/// scope, which is a soft limit. You should configure something like
/// `systemd-oom` to handle killing processes that have too much memory
/// pressure.
///
/// Changing this value and reloading the config will not affect existing
/// surfaces.
///
/// See the `systemd.resource-control` manual page for more information:
/// https://www.freedesktop.org/software/systemd/man/latest/systemd.resource-control.html
@"linux-cgroup-memory-limit": ?u64 = null,
/// Number of processes limit for any individual terminal process (tab, split,
/// window, etc.). If this is unset then no limit will be set.
///
/// Note that this sets the "pids.max" configuration for the process number
/// controller, which is a hard limit.
/// Note that this sets the `TasksMax` setting on the transient `systemd` scope,
/// which is a hard limit.
///
/// Changing this value and reloading the config will not affect existing
/// surfaces.
///
/// See the `systemd.resource-control` manual page for more information:
/// https://www.freedesktop.org/software/systemd/man/latest/systemd.resource-control.html
@"linux-cgroup-processes-limit": ?u64 = null,
/// If this is false, then any cgroup initialization (for linux-cgroup)
/// will be allowed to fail and the failure is ignored. This is useful if
/// you view cgroup isolation as a "nice to have" and not a critical resource
/// management feature, because Ghostty startup will not fail if cgroup APIs
/// fail.
/// If this is false, then creating a transient `systemd` scope (for
/// `linux-cgroup`) will be allowed to fail and the failure is ignored. This is
/// useful if you view cgroup isolation as a "nice to have" and not a critical
/// resource management feature, because surface creation will not fail if
/// `systemd` APIs fail.
///
/// If this is true, then any cgroup initialization failure will cause
/// Ghostty to exit or new surfaces to not be created.
/// If this is true, then any transient `systemd` scope creation failure will
/// cause surface creation to fail.
///
/// Note: This currently only affects cgroup initialization. Subprocesses
/// must always be able to move themselves into an isolated cgroup.
/// Changing this value and reloading the config will not affect existing
/// surfaces.
@"linux-cgroup-hard-fail": bool = false,
/// Enable or disable GTK's OpenGL debugging logs. The default is `true` for
@@ -5530,14 +5566,16 @@ pub const ColorList = struct {
}
};
/// Palette is the 256 color palette for 256-color mode. This is still
/// used by many terminal applications.
/// Palette is the 256 color palette for 256-color mode.
pub const Palette = struct {
const Self = @This();
/// The actual value that is updated as we parse.
value: terminal.color.Palette = terminal.color.default,
/// Keep track of which indexes were manually set by the user.
mask: terminal.color.PaletteMask = .initEmpty(),
/// ghostty_config_palette_s
pub const C = extern struct {
colors: [265]Color.C,
@@ -5574,6 +5612,7 @@ pub const Palette = struct {
// Parse the color part (Color.parseCLI will handle whitespace)
const rgb = try Color.parseCLI(value[eqlIdx + 1 ..]);
self.value[key] = .{ .r = rgb.r, .g = rgb.g, .b = rgb.b };
self.mask.set(key);
}
/// Deep copy of the struct. Required by Config.
@@ -5609,6 +5648,8 @@ pub const Palette = struct {
try testing.expect(p.value[0].r == 0xAA);
try testing.expect(p.value[0].g == 0xBB);
try testing.expect(p.value[0].b == 0xCC);
try testing.expect(p.mask.isSet(0));
try testing.expect(!p.mask.isSet(1));
}
test "parseCLI base" {
@@ -5631,6 +5672,12 @@ pub const Palette = struct {
try testing.expect(p.value[0xF].r == 0xAB);
try testing.expect(p.value[0xF].g == 0xCD);
try testing.expect(p.value[0xF].b == 0xEF);
try testing.expect(p.mask.isSet(0b1));
try testing.expect(p.mask.isSet(0o7));
try testing.expect(p.mask.isSet(0xF));
try testing.expect(!p.mask.isSet(0));
try testing.expect(!p.mask.isSet(2));
}
test "parseCLI overflow" {
@@ -5638,6 +5685,8 @@ pub const Palette = struct {
var p: Self = .{};
try testing.expectError(error.Overflow, p.parseCLI("256=#AABBCC"));
// Mask should remain empty since parsing failed.
try testing.expectEqual(@as(usize, 0), p.mask.count());
}
test "formatConfig" {
@@ -5669,6 +5718,11 @@ pub const Palette = struct {
try testing.expect(p.value[2].r == 0x12);
try testing.expect(p.value[2].g == 0x34);
try testing.expect(p.value[2].b == 0x56);
try testing.expect(p.mask.isSet(0));
try testing.expect(p.mask.isSet(1));
try testing.expect(p.mask.isSet(2));
try testing.expect(!p.mask.isSet(3));
}
};

View File

@@ -265,7 +265,7 @@ pub fn SplitTree(comptime V: type) type {
// Get our spatial representation.
var sp = try self.spatial(alloc);
defer sp.deinit(alloc);
break :spatial self.nearest(sp, from, d);
break :spatial self.nearestWrapped(sp, from, d);
},
};
}
@@ -385,14 +385,15 @@ pub fn SplitTree(comptime V: type) type {
}
/// Returns the nearest leaf node (view) in the given direction.
/// This does not handle wrapping and will return null if there
/// is no node in that direction.
fn nearest(
self: *const Self,
sp: Spatial,
from: Node.Handle,
direction: Spatial.Direction,
target: Spatial.Slot,
) ?Node.Handle {
const target = sp.slots[from.idx()];
var result: ?struct {
handle: Node.Handle,
distance: f16,
@@ -433,6 +434,45 @@ pub fn SplitTree(comptime V: type) type {
return if (result) |n| n.handle else null;
}
/// Same as nearest but supports wrapping.
fn nearestWrapped(
self: *const Self,
sp: Spatial,
from: Node.Handle,
direction: Spatial.Direction,
) ?Node.Handle {
// If we can find a nearest value without wrapping, then
// use that.
var target = sp.slots[from.idx()];
if (self.nearest(
sp,
from,
direction,
target,
)) |v| return v;
// The spatial grid is normalized to 1x1, so wrapping is modeled
// by shifting the target slot by one full grid in the opposite
// direction and reusing the same nearest distance logic.
// We don't actually modify the grid or spatial representation,
// this just fakes it.
assert(target.x >= 0 and target.y >= 0);
assert(target.maxX() <= 1 and target.maxY() <= 1);
switch (direction) {
.left => target.x += 1,
.right => target.x -= 1,
.up => target.y += 1,
.down => target.y -= 1,
}
return self.nearest(
sp,
from,
direction,
target,
);
}
/// Resize the given node in place. The node MUST be a split (asserted).
///
/// In general, this is an immutable data structure so this is
@@ -1974,6 +2014,60 @@ test "SplitTree: spatial goto" {
try testing.expectEqualStrings("A", view.label);
}
// Spatial A => left (wrapped)
{
const target = (try split.goto(
alloc,
from: {
var it = split.iterator();
break :from while (it.next()) |entry| {
if (std.mem.eql(u8, entry.view.label, "A")) {
break entry.handle;
}
} else return error.NotFound;
},
.{ .spatial = .left },
)).?;
const view = split.nodes[target.idx()].leaf;
try testing.expectEqualStrings("B", view.label);
}
// Spatial B => right (wrapped)
{
const target = (try split.goto(
alloc,
from: {
var it = split.iterator();
break :from while (it.next()) |entry| {
if (std.mem.eql(u8, entry.view.label, "B")) {
break entry.handle;
}
} else return error.NotFound;
},
.{ .spatial = .right },
)).?;
const view = split.nodes[target.idx()].leaf;
try testing.expectEqualStrings("A", view.label);
}
// Spatial C => down (wrapped)
{
const target = (try split.goto(
alloc,
from: {
var it = split.iterator();
break :from while (it.next()) |entry| {
if (std.mem.eql(u8, entry.view.label, "C")) {
break entry.handle;
}
} else return error.NotFound;
},
.{ .spatial = .down },
)).?;
const view = split.nodes[target.idx()].leaf;
try testing.expectEqualStrings("A", view.label);
}
// Equalize
var equal = try split.equalize(alloc);
defer equal.deinit();

View File

@@ -40,6 +40,12 @@ fn writeBashCompletions(writer: *std.Io.Writer) !void {
try writer.writeAll(
\\_ghostty() {
\\
\\ # compat: mapfile -t COMPREPLY < <( "$@" )
\\ _compreply() {
\\ COMPREPLY=()
\\ while IFS='' read -r line; do COMPREPLY+=("$line"); done < <( "$@" )
\\ }
\\
\\ # -o nospace requires we add back a space when a completion is finished
\\ # and not part of a --key= completion
\\ _add_spaces() {
@@ -50,16 +56,18 @@ fn writeBashCompletions(writer: *std.Io.Writer) !void {
\\
\\ _fonts() {
\\ local IFS=$'\n'
\\ mapfile -t COMPREPLY < <( compgen -P '"' -S '"' -W "$($ghostty +list-fonts | grep '^[A-Z]' )" -- "$cur")
\\ COMPREPLY=()
\\ while read -r line; do COMPREPLY+=("$line"); done < <( compgen -P '"' -S '"' -W "$($ghostty +list-fonts | grep '^[A-Z]' )" -- "$cur")
\\ }
\\
\\ _themes() {
\\ local IFS=$'\n'
\\ mapfile -t COMPREPLY < <( compgen -P '"' -S '"' -W "$($ghostty +list-themes | sed -E 's/^(.*) \(.*$/\1/')" -- "$cur")
\\ COMPREPLY=()
\\ while read -r line; do COMPREPLY+=("$line"); done < <( compgen -P '"' -S '"' -W "$($ghostty +list-themes | sed -E 's/^(.*) \(.*$/\1/')" -- "$cur")
\\ }
\\
\\ _files() {
\\ mapfile -t COMPREPLY < <( compgen -o filenames -f -- "$cur" )
\\ _compreply compgen -o filenames -f -- "$cur"
\\ for i in "${!COMPREPLY[@]}"; do
\\ if [[ -d "${COMPREPLY[i]}" ]]; then
\\ COMPREPLY[i]="${COMPREPLY[i]}/";
@@ -71,7 +79,7 @@ fn writeBashCompletions(writer: *std.Io.Writer) !void {
\\ }
\\
\\ _dirs() {
\\ mapfile -t COMPREPLY < <( compgen -o dirnames -d -- "$cur" )
\\ _compreply compgen -o dirnames -d -- "$cur"
\\ for i in "${!COMPREPLY[@]}"; do
\\ if [[ -d "${COMPREPLY[i]}" ]]; then
\\ COMPREPLY[i]="${COMPREPLY[i]}/";
@@ -115,8 +123,8 @@ fn writeBashCompletions(writer: *std.Io.Writer) !void {
else if (field.type == Config.RepeatablePath)
try writer.writeAll("_files ;;")
else {
const compgenPrefix = "mapfile -t COMPREPLY < <( compgen -W \"";
const compgenSuffix = "\" -- \"$cur\" ); _add_spaces ;;";
const compgenPrefix = "_compreply compgen -W \"";
const compgenSuffix = "\" -- \"$cur\"; _add_spaces ;;";
switch (@typeInfo(field.type)) {
.bool => try writer.writeAll("return ;;"),
.@"enum" => |info| {
@@ -147,7 +155,7 @@ fn writeBashCompletions(writer: *std.Io.Writer) !void {
}
try writer.writeAll(
\\ *) mapfile -t COMPREPLY < <( compgen -W "$config" -- "$cur" ) ;;
\\ *) _compreply compgen -W "$config" -- "$cur" ;;
\\ esac
\\
\\ return 0
@@ -206,8 +214,8 @@ fn writeBashCompletions(writer: *std.Io.Writer) !void {
try writer.writeAll(pad5 ++ "--" ++ opt.name ++ ") ");
const compgenPrefix = "mapfile -t COMPREPLY < <( compgen -W \"";
const compgenSuffix = "\" -- \"$cur\" ); _add_spaces ;;";
const compgenPrefix = "_compreply compgen -W \"";
const compgenSuffix = "\" -- \"$cur\"; _add_spaces ;;";
switch (@typeInfo(opt.type)) {
.bool => try writer.writeAll("return ;;"),
.@"enum" => |info| {
@@ -243,7 +251,7 @@ fn writeBashCompletions(writer: *std.Io.Writer) !void {
}
try writer.writeAll("\n");
}
try writer.writeAll(pad5 ++ "*) mapfile -t COMPREPLY < <( compgen -W \"$" ++ bashName ++ "\" -- \"$cur\" ) ;;\n");
try writer.writeAll(pad5 ++ "*) _compreply compgen -W \"$" ++ bashName ++ "\" -- \"$cur\" ;;\n");
try writer.writeAll(
\\ esac
\\ ;;
@@ -252,7 +260,7 @@ fn writeBashCompletions(writer: *std.Io.Writer) !void {
}
try writer.writeAll(
\\ *) mapfile -t COMPREPLY < <( compgen -W "--help" -- "$cur" ) ;;
\\ *) _compreply compgen -W "--help" -- "$cur" ;;
\\ esac
\\
\\ return 0
@@ -298,7 +306,7 @@ fn writeBashCompletions(writer: *std.Io.Writer) !void {
\\ case "${COMP_WORDS[1]}" in
\\ -e | --help | --version) return 0 ;;
\\ --*) _handle_config ;;
\\ *) mapfile -t COMPREPLY < <( compgen -W "${topLevel}" -- "$cur" ); _add_spaces ;;
\\ *) _compreply compgen -W "${topLevel}" -- "$cur"; _add_spaces ;;
\\ esac
\\ ;;
\\ *)

View File

@@ -1,254 +1,26 @@
const std = @import("std");
const assert = @import("../quirks.zig").inlineAssert;
const linux = std.os.linux;
const posix = std.posix;
const Allocator = std.mem.Allocator;
const log = std.log.scoped(.@"linux-cgroup");
/// Returns the path to the cgroup for the given pid.
pub fn current(alloc: Allocator, pid: std.os.linux.pid_t) !?[]const u8 {
var buf: [std.fs.max_path_bytes]u8 = undefined;
pub fn current(buf: []u8, pid: u32) ?[]const u8 {
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
// Read our cgroup by opening /proc/<pid>/cgroup and reading the first
// line. The first line will look something like this:
// 0::/user.slice/user-1000.slice/session-1.scope
// The cgroup path is the third field.
const path = try std.fmt.bufPrint(&buf, "/proc/{}/cgroup", .{pid});
const file = try std.fs.cwd().openFile(path, .{});
const path = std.fmt.bufPrint(&path_buf, "/proc/{}/cgroup", .{pid}) catch return null;
const file = std.fs.openFileAbsolute(path, .{}) catch return null;
defer file.close();
// Read it all into memory -- we don't expect this file to ever be that large.
const contents = try file.readToEndAlloc(
alloc,
1 * 1024 * 1024, // 1MB
);
defer alloc.free(contents);
var read_buf: [64]u8 = undefined;
var file_reader = file.reader(&read_buf);
const reader = &file_reader.interface;
const len = reader.readSliceShort(buf) catch return null;
const contents = buf[0..len];
// Find the last ':'
const idx = std.mem.lastIndexOfScalar(u8, contents, ':') orelse return null;
const result = std.mem.trimRight(u8, contents[idx + 1 ..], " \r\n");
return try alloc.dupe(u8, result);
}
/// Create a new cgroup. This will not move any process into it unless move is
/// set. If move is set, the given pid will be moved into the created cgroup.
pub fn create(
cgroup: []const u8,
child: []const u8,
move: ?std.os.linux.pid_t,
) !void {
var buf: [std.fs.max_path_bytes]u8 = undefined;
const path = try std.fmt.bufPrint(&buf, "/sys/fs/cgroup{s}/{s}", .{ cgroup, child });
try std.fs.cwd().makePath(path);
// If we have a PID to move into the cgroup immediately, do it.
if (move) |pid| {
const pid_path = try std.fmt.bufPrint(
&buf,
"/sys/fs/cgroup{s}/{s}/cgroup.procs",
.{ cgroup, child },
);
const file = try std.fs.cwd().openFile(pid_path, .{ .mode = .write_only });
defer file.close();
var file_buf: [64]u8 = undefined;
var writer = file.writer(&file_buf);
try writer.interface.print("{}", .{pid});
try writer.interface.flush();
}
}
/// Remove a cgroup. This will only succeed if the cgroup is empty
/// (has no processes). The cgroup path should be relative to the
/// cgroup root (e.g. "/user.slice/surfaces/abc123.scope").
pub fn remove(cgroup: []const u8) !void {
assert(cgroup.len > 0);
assert(cgroup[0] == '/');
var buf: [std.fs.max_path_bytes]u8 = undefined;
const path = try std.fmt.bufPrint(&buf, "/sys/fs/cgroup{s}", .{cgroup});
std.fs.cwd().deleteDir(path) catch |err| switch (err) {
// If it doesn't exist, that's fine - maybe it was already cleaned up
error.FileNotFound => {},
// Any other error we failed to delete it so we want to notify
// the user.
else => return err,
};
}
/// Move the given PID into the given cgroup.
pub fn moveInto(
cgroup: []const u8,
pid: std.os.linux.pid_t,
) !void {
var buf: [std.fs.max_path_bytes]u8 = undefined;
const path = try std.fmt.bufPrint(&buf, "/sys/fs/cgroup{s}/cgroup.procs", .{cgroup});
const file = try std.fs.cwd().openFile(path, .{ .mode = .write_only });
defer file.close();
try file.writer().print("{}", .{pid});
}
/// Use clone3 to have the kernel create a new process with the correct cgroup
/// rather than moving the process to the correct cgroup later.
pub fn cloneInto(cgroup: []const u8) !posix.pid_t {
var buf: [std.fs.max_path_bytes]u8 = undefined;
const path = try std.fmt.bufPrintZ(&buf, "/sys/fs/cgroup{s}", .{cgroup});
// Get a file descriptor that refers to the cgroup directory in the cgroup
// sysfs to pass to the kernel in clone3.
const fd: linux.fd_t = fd: {
const rc = linux.open(
path,
.{
// Self-explanatory: we expect to open a directory, and
// we only need the path-level permissions.
.PATH = true,
.DIRECTORY = true,
// We don't want to leak this fd to the child process
// when we clone below since we're using this fd for
// a cgroup clone.
.CLOEXEC = true,
},
0,
);
switch (posix.errno(rc)) {
.SUCCESS => break :fd @as(linux.fd_t, @intCast(rc)),
else => |errno| {
log.err("unable to open cgroup dir {s}: {}", .{ path, errno });
return error.CloneError;
},
}
};
assert(fd >= 0);
defer _ = linux.close(fd);
const args: extern struct {
flags: u64,
pidfd: u64,
child_tid: u64,
parent_tid: u64,
exit_signal: u64,
stack: u64,
stack_size: u64,
tls: u64,
set_tid: u64,
set_tid_size: u64,
cgroup: u64,
} = .{
.flags = linux.CLONE.INTO_CGROUP,
.pidfd = 0,
.child_tid = 0,
.parent_tid = 0,
.exit_signal = linux.SIG.CHLD,
.stack = 0,
.stack_size = 0,
.tls = 0,
.set_tid = 0,
.set_tid_size = 0,
.cgroup = @intCast(fd),
};
const rc = linux.syscall2(linux.SYS.clone3, @intFromPtr(&args), @sizeOf(@TypeOf(args)));
// do not use posix.errno, when linking libc it will use the libc errno which will not be set when making the syscall directly
return switch (std.os.linux.E.init(rc)) {
.SUCCESS => @as(posix.pid_t, @intCast(rc)),
else => |errno| err: {
log.err("unable to clone: {}", .{errno});
break :err error.CloneError;
},
};
}
/// Returns all available cgroup controllers for the given cgroup.
/// The cgroup should have a '/'-prefix.
///
/// The returned list of is the raw space-separated list of
/// controllers from the /sys/fs directory. This avoids some extra
/// work since creating an iterator over this is easy and much cheaper
/// than allocating a bunch of copies for an array.
pub fn controllers(alloc: Allocator, cgroup: []const u8) ![]const u8 {
assert(cgroup[0] == '/');
var buf: [std.fs.max_path_bytes]u8 = undefined;
// Read the available controllers. These will be space separated.
const path = try std.fmt.bufPrint(
&buf,
"/sys/fs/cgroup{s}/cgroup.controllers",
.{cgroup},
);
const file = try std.fs.cwd().openFile(path, .{});
defer file.close();
// Read it all into memory -- we don't expect this file to ever
// be that large.
const contents = try file.readToEndAlloc(
alloc,
1 * 1024 * 1024, // 1MB
);
defer alloc.free(contents);
// Return our raw list of controllers
const result = std.mem.trimRight(u8, contents, " \r\n");
return try alloc.dupe(u8, result);
}
/// Configure the set of controllers in the cgroup. The "v" should
/// be in a valid format for "cgroup.subtree_control"
pub fn configureControllers(
cgroup: []const u8,
v: []const u8,
) !void {
assert(cgroup[0] == '/');
var buf: [std.fs.max_path_bytes]u8 = undefined;
// Read the available controllers. These will be space separated.
const path = try std.fmt.bufPrint(
&buf,
"/sys/fs/cgroup{s}/cgroup.subtree_control",
.{cgroup},
);
const file = try std.fs.cwd().openFile(path, .{ .mode = .write_only });
defer file.close();
// Write
var writer_buf: [4096]u8 = undefined;
var writer = file.writer(&writer_buf);
try writer.interface.writeAll(v);
try writer.interface.flush();
}
pub const Limit = union(enum) {
memory_high: usize,
pids_max: usize,
};
/// Configure a limit for the given cgroup. Use the various
/// fields in Limit to configure a specific type of limit.
pub fn configureLimit(cgroup: []const u8, limit: Limit) !void {
assert(cgroup[0] == '/');
const filename, const size = switch (limit) {
.memory_high => |v| .{ "memory.high", v },
.pids_max => |v| .{ "pids.max", v },
};
// Open our file
var buf: [std.fs.max_path_bytes]u8 = undefined;
const path = try std.fmt.bufPrint(
&buf,
"/sys/fs/cgroup{s}/{s}",
.{ cgroup, filename },
);
const file = try std.fs.cwd().openFile(path, .{ .mode = .write_only });
defer file.close();
// Write our limit in bytes
var writer_buf: [4096]u8 = undefined;
var writer = file.writer(&writer_buf);
try writer.interface.print("{}", .{size});
try writer.interface.flush();
return std.mem.trimRight(u8, contents[idx + 1 ..], " \r\n");
}

View File

@@ -47,6 +47,115 @@ pub const default: Palette = default: {
/// Palette is the 256 color palette.
pub const Palette = [256]RGB;
/// Mask that can be used to set which palette indexes were set.
pub const PaletteMask = std.StaticBitSet(@typeInfo(Palette).array.len);
/// Generate the 256-color palette from the user's base16 theme colors,
/// terminal background, and terminal foreground.
///
/// Motivation: The default 256-color palette uses fixed, fully-saturated
/// colors that clash with custom base16 themes, have poor readability in
/// dark shades (the first non-black shade jumps to 37% intensity instead
/// of the expected 20%), and exhibit inconsistent perceived brightness
/// across hues of the same shade (e.g., blue appears darker than green).
/// By generating the extended palette from the user's chosen colors,
/// programs can use the richer 256-color range without requiring their
/// own theme configuration, and light/dark switching works automatically.
///
/// The 216-color cube (indices 16231) is built via trilinear
/// interpolation in CIELAB space over the 8 base colors. The base16
/// palette maps to the 8 corners of a 6×6×6 RGB cube as follows:
///
/// R=0 edge: bg → base[1] (red)
/// R=5 edge: base[6] → fg
/// G=0 edge: bg/base[6] (via R) → base[2]/base[4] (green/blue via R)
/// G=5 edge: base[1]/fg (via R) → base[3]/base[5] (yellow/magenta via R)
///
/// For each R slice, four corner colors (c0c3) are interpolated along
/// the R axis, then for each G row two edge colors (c4c5) are
/// interpolated along G, and finally each B cell is interpolated along B
/// to produce the final color. CIELAB interpolation ensures perceptually
/// uniform brightness transitions across different hues.
///
/// The 24-step grayscale ramp (indices 232255) is a simple linear
/// interpolation in CIELAB from the background to the foreground,
/// excluding pure black and white (available in the cube at (0,0,0)
/// and (5,5,5)). The interpolation parameter runs from 1/25 to 24/25.
///
/// Fill `skip` with user-defined color indexes to avoid replacing them.
///
/// Reference: https://gist.github.com/jake-stewart/0a8ea46159a7da2c808e5be2177e1783
pub fn generate256Color(
base: Palette,
skip: PaletteMask,
bg: RGB,
fg: RGB,
) Palette {
// Convert the background, foreground, and 8 base theme colors into
// CIELAB space so that all interpolation is perceptually uniform.
const bg_lab: LAB = .fromRgb(bg);
const fg_lab: LAB = .fromRgb(fg);
const base8_lab: [8]LAB = base8: {
var base8: [8]LAB = undefined;
for (0..8) |i| base8[i] = .fromRgb(base[i]);
break :base8 base8;
};
// Start from the base palette so indices 015 are preserved as-is.
var result = base;
// Build the 216-color cube (indices 16231) via trilinear interpolation
// in CIELAB. The three nested loops correspond to the R, G, and B axes
// of a 6×6×6 cube. For each R slice, four corner colors (c0c3) are
// interpolated along R from the 8 base colors, mapping the cube corners
// to theme-aware anchors (see doc comment for the mapping). Then for
// each G row, two edge colors (c4c5) blend along G, and finally each
// B cell interpolates along B to produce the final color.
var idx: usize = 16;
for (0..6) |ri| {
// R-axis corners: blend base colors along the red dimension.
const tr = @as(f32, @floatFromInt(ri)) / 5.0;
const c0: LAB = .lerp(tr, bg_lab, base8_lab[1]);
const c1: LAB = .lerp(tr, base8_lab[2], base8_lab[3]);
const c2: LAB = .lerp(tr, base8_lab[4], base8_lab[5]);
const c3: LAB = .lerp(tr, base8_lab[6], fg_lab);
for (0..6) |gi| {
// G-axis edges: blend the R-interpolated corners along green.
const tg = @as(f32, @floatFromInt(gi)) / 5.0;
const c4: LAB = .lerp(tg, c0, c1);
const c5: LAB = .lerp(tg, c2, c3);
for (0..6) |bi| {
// B-axis: final interpolation along blue, then convert back to RGB.
if (!skip.isSet(idx)) {
const c6: LAB = .lerp(
@as(f32, @floatFromInt(bi)) / 5.0,
c4,
c5,
);
result[idx] = c6.toRgb();
}
idx += 1;
}
}
}
// Build the 24-step grayscale ramp (indices 232255) by linearly
// interpolating in CIELAB from background to foreground. The parameter
// runs from 1/25 to 24/25, excluding the endpoints which are already
// available in the cube at (0,0,0) and (5,5,5).
for (0..24) |i| {
const t = @as(f32, @floatFromInt(i + 1)) / 25.0;
if (!skip.isSet(idx)) {
const c: LAB = .lerp(t, bg_lab, fg_lab);
result[idx] = c.toRgb();
}
idx += 1;
}
return result;
}
/// A palette that can have its colors changed and reset. Purposely built
/// for terminal color operations.
pub const DynamicPalette = struct {
@@ -58,9 +167,7 @@ pub const DynamicPalette = struct {
/// A bitset where each bit represents whether the corresponding
/// palette index has been modified from its default value.
mask: Mask,
const Mask = std.StaticBitSet(@typeInfo(Palette).array.len);
mask: PaletteMask,
pub const default: DynamicPalette = .init(colorpkg.default);
@@ -519,6 +626,101 @@ pub const RGB = packed struct(u24) {
}
};
/// LAB color space
const LAB = struct {
l: f32,
a: f32,
b: f32,
/// RGB to LAB
pub fn fromRgb(rgb: RGB) LAB {
// Step 1: Normalize sRGB channels from [0, 255] to [0.0, 1.0].
var r: f32 = @as(f32, @floatFromInt(rgb.r)) / 255.0;
var g: f32 = @as(f32, @floatFromInt(rgb.g)) / 255.0;
var b: f32 = @as(f32, @floatFromInt(rgb.b)) / 255.0;
// Step 2: Apply the inverse sRGB companding (gamma correction) to
// convert from sRGB to linear RGB. The sRGB transfer function has
// two segments: a linear portion for small values and a power curve
// for the rest.
r = if (r > 0.04045) std.math.pow(f32, (r + 0.055) / 1.055, 2.4) else r / 12.92;
g = if (g > 0.04045) std.math.pow(f32, (g + 0.055) / 1.055, 2.4) else g / 12.92;
b = if (b > 0.04045) std.math.pow(f32, (b + 0.055) / 1.055, 2.4) else b / 12.92;
// Step 3: Convert linear RGB to CIE XYZ using the sRGB to XYZ
// transformation matrix (D65 illuminant). The X and Z values are
// normalized by the D65 white point reference values (Xn=0.95047,
// Zn=1.08883; Yn=1.0 is implicit).
var x = (r * 0.4124564 + g * 0.3575761 + b * 0.1804375) / 0.95047;
var y = r * 0.2126729 + g * 0.7151522 + b * 0.0721750;
var z = (r * 0.0193339 + g * 0.1191920 + b * 0.9503041) / 1.08883;
// Step 4: Apply the CIE f(t) nonlinear transform to each XYZ
// component. Above the threshold (epsilon ≈ 0.008856) the cube
// root is used; below it, a linear approximation avoids numerical
// instability near zero.
x = if (x > 0.008856) std.math.cbrt(x) else 7.787 * x + 16.0 / 116.0;
y = if (y > 0.008856) std.math.cbrt(y) else 7.787 * y + 16.0 / 116.0;
z = if (z > 0.008856) std.math.cbrt(z) else 7.787 * z + 16.0 / 116.0;
// Step 5: Compute the final CIELAB values from the transformed XYZ.
// L* is lightness (0100), a* is greenred, b* is blueyellow.
return .{ .l = 116.0 * y - 16.0, .a = 500.0 * (x - y), .b = 200.0 * (y - z) };
}
/// LAB to RGB
pub fn toRgb(self: LAB) RGB {
// Step 1: Recover the intermediate f(Y), f(X), f(Z) values from
// L*a*b* by inverting the CIELAB formulas.
const y = (self.l + 16.0) / 116.0;
const x = self.a / 500.0 + y;
const z = y - self.b / 200.0;
// Step 2: Apply the inverse CIE f(t) transform to get back to
// XYZ. Above epsilon (≈0.008856) the cube is used; below it the
// linear segment is inverted. Results are then scaled by the D65
// white point reference values (Xn=0.95047, Zn=1.08883; Yn=1.0).
const x3 = x * x * x;
const y3 = y * y * y;
const z3 = z * z * z;
const xf = (if (x3 > 0.008856) x3 else (x - 16.0 / 116.0) / 7.787) * 0.95047;
const yf = if (y3 > 0.008856) y3 else (y - 16.0 / 116.0) / 7.787;
const zf = (if (z3 > 0.008856) z3 else (z - 16.0 / 116.0) / 7.787) * 1.08883;
// Step 3: Convert CIE XYZ back to linear RGB using the XYZ to sRGB
// matrix (inverse of the sRGB to XYZ matrix, D65 illuminant).
var r = xf * 3.2404542 - yf * 1.5371385 - zf * 0.4985314;
var g = -xf * 0.9692660 + yf * 1.8760108 + zf * 0.0415560;
var b = xf * 0.0556434 - yf * 0.2040259 + zf * 1.0572252;
// Step 4: Apply sRGB companding (gamma correction) to convert from
// linear RGB back to sRGB. This is the forward sRGB transfer
// function with the same two-segment split as the inverse.
r = if (r > 0.0031308) 1.055 * std.math.pow(f32, r, 1.0 / 2.4) - 0.055 else 12.92 * r;
g = if (g > 0.0031308) 1.055 * std.math.pow(f32, g, 1.0 / 2.4) - 0.055 else 12.92 * g;
b = if (b > 0.0031308) 1.055 * std.math.pow(f32, b, 1.0 / 2.4) - 0.055 else 12.92 * b;
// Step 5: Clamp to [0.0, 1.0], scale to [0, 255], and round to
// the nearest integer to produce the final 8-bit sRGB values.
return .{
.r = @intFromFloat(@min(@max(r, 0.0), 1.0) * 255.0 + 0.5),
.g = @intFromFloat(@min(@max(g, 0.0), 1.0) * 255.0 + 0.5),
.b = @intFromFloat(@min(@max(b, 0.0), 1.0) * 255.0 + 0.5),
};
}
/// Linearly interpolate between two LAB colors component-wise.
/// `t` is the interpolation factor in [0, 1]: t=0 returns `a`,
/// t=1 returns `b`, and values in between blend proportionally.
pub fn lerp(t: f32, a: LAB, b: LAB) LAB {
return .{
.l = a.l + t * (b.l - a.l),
.a = a.a + t * (b.a - a.a),
.b = a.b + t * (b.b - a.b),
};
}
};
test "palette: default" {
const testing = std.testing;
@@ -683,3 +885,126 @@ test "DynamicPalette: changeDefault with multiple changes" {
try testing.expectEqual(blue, p.current[3]);
try testing.expectEqual(@as(usize, 3), p.mask.count());
}
test "LAB.fromRgb" {
const testing = std.testing;
const epsilon = 0.5;
// White (255, 255, 255) -> L*=100, a*=0, b*=0
const white = LAB.fromRgb(.{ .r = 255, .g = 255, .b = 255 });
try testing.expectApproxEqAbs(@as(f32, 100.0), white.l, epsilon);
try testing.expectApproxEqAbs(@as(f32, 0.0), white.a, epsilon);
try testing.expectApproxEqAbs(@as(f32, 0.0), white.b, epsilon);
// Black (0, 0, 0) -> L*=0, a*=0, b*=0
const black = LAB.fromRgb(.{ .r = 0, .g = 0, .b = 0 });
try testing.expectApproxEqAbs(@as(f32, 0.0), black.l, epsilon);
try testing.expectApproxEqAbs(@as(f32, 0.0), black.a, epsilon);
try testing.expectApproxEqAbs(@as(f32, 0.0), black.b, epsilon);
// Pure red (255, 0, 0) -> L*≈53.23, a*≈80.11, b*≈67.22
const red = LAB.fromRgb(.{ .r = 255, .g = 0, .b = 0 });
try testing.expectApproxEqAbs(@as(f32, 53.23), red.l, epsilon);
try testing.expectApproxEqAbs(@as(f32, 80.11), red.a, epsilon);
try testing.expectApproxEqAbs(@as(f32, 67.22), red.b, epsilon);
// Pure green (0, 128, 0) -> L*≈46.23, a*≈-51.70, b*≈49.90
const green = LAB.fromRgb(.{ .r = 0, .g = 128, .b = 0 });
try testing.expectApproxEqAbs(@as(f32, 46.23), green.l, epsilon);
try testing.expectApproxEqAbs(@as(f32, -51.70), green.a, epsilon);
try testing.expectApproxEqAbs(@as(f32, 49.90), green.b, epsilon);
// Pure blue (0, 0, 255) -> L*≈32.30, a*≈79.20, b*≈-107.86
const blue = LAB.fromRgb(.{ .r = 0, .g = 0, .b = 255 });
try testing.expectApproxEqAbs(@as(f32, 32.30), blue.l, epsilon);
try testing.expectApproxEqAbs(@as(f32, 79.20), blue.a, epsilon);
try testing.expectApproxEqAbs(@as(f32, -107.86), blue.b, epsilon);
}
test "generate256Color: base16 preserved" {
const testing = std.testing;
const bg = RGB{ .r = 0, .g = 0, .b = 0 };
const fg = RGB{ .r = 255, .g = 255, .b = 255 };
const palette = generate256Color(default, .initEmpty(), bg, fg);
// The first 16 colors (base16) must remain unchanged.
for (0..16) |i| {
try testing.expectEqual(default[i], palette[i]);
}
}
test "generate256Color: cube corners match base colors" {
const testing = std.testing;
const bg = RGB{ .r = 0, .g = 0, .b = 0 };
const fg = RGB{ .r = 255, .g = 255, .b = 255 };
const palette = generate256Color(default, .initEmpty(), bg, fg);
// Index 16 is cube (0,0,0) which should equal bg.
try testing.expectEqual(bg, palette[16]);
// Index 231 is cube (5,5,5) which should equal fg.
try testing.expectEqual(fg, palette[231]);
}
test "generate256Color: grayscale ramp monotonic luminance" {
const testing = std.testing;
const bg = RGB{ .r = 0, .g = 0, .b = 0 };
const fg = RGB{ .r = 255, .g = 255, .b = 255 };
const palette = generate256Color(default, .initEmpty(), bg, fg);
// The grayscale ramp (232255) should have monotonically increasing
// luminance from near-black to near-white.
var prev_lum: f64 = 0.0;
for (232..256) |i| {
const lum = palette[i].luminance();
try testing.expect(lum >= prev_lum);
prev_lum = lum;
}
}
test "generate256Color: skip mask preserves original colors" {
const testing = std.testing;
const bg = RGB{ .r = 0, .g = 0, .b = 0 };
const fg = RGB{ .r = 255, .g = 255, .b = 255 };
// Mark a few indices as skipped; they should keep their base value.
var skip: PaletteMask = .initEmpty();
skip.set(20);
skip.set(100);
skip.set(240);
const palette = generate256Color(default, skip, bg, fg);
try testing.expectEqual(default[20], palette[20]);
try testing.expectEqual(default[100], palette[100]);
try testing.expectEqual(default[240], palette[240]);
// A non-skipped index in the cube should differ from the default.
try testing.expect(!palette[21].eql(default[21]));
}
test "LAB.toRgb" {
const testing = std.testing;
// Round-trip: RGB -> LAB -> RGB should recover the original values.
const cases = [_]RGB{
.{ .r = 255, .g = 255, .b = 255 },
.{ .r = 0, .g = 0, .b = 0 },
.{ .r = 255, .g = 0, .b = 0 },
.{ .r = 0, .g = 128, .b = 0 },
.{ .r = 0, .g = 0, .b = 255 },
.{ .r = 128, .g = 128, .b = 128 },
.{ .r = 64, .g = 224, .b = 208 },
};
for (cases) |expected| {
const lab = LAB.fromRgb(expected);
const actual = lab.toRgb();
try testing.expectEqual(expected.r, actual.r);
try testing.expectEqual(expected.g, actual.g);
try testing.expectEqual(expected.b, actual.b);
}
}

View File

@@ -4,6 +4,7 @@ const Allocator = std.mem.Allocator;
const color = @import("color.zig");
const size = @import("size.zig");
const charsets = @import("charsets.zig");
const hyperlink = @import("hyperlink.zig");
const kitty = @import("kitty.zig");
const modespkg = @import("modes.zig");
const Screen = @import("Screen.zig");
@@ -996,6 +997,10 @@ pub const PageFormatter = struct {
// Our style for non-plain formats
var style: Style = .{};
// Track hyperlink state for HTML output. We need to close </a> tags
// when the hyperlink changes or ends.
var current_hyperlink_id: ?hyperlink.Id = null;
for (start_y..end_y + 1) |y_usize| {
const y: size.CellCountInt = @intCast(y_usize);
const row: *Row = self.page.getRow(y);
@@ -1232,6 +1237,63 @@ pub const PageFormatter = struct {
}
}
// Hyperlink state
hyperlink: {
// We currently only emit hyperlinks for HTML. In the
// future we can support emitting OSC 8 hyperlinks for
// VT output as well.
if (self.opts.emit != .html) break :hyperlink;
// Get the hyperlink ID. This ID is our internal ID,
// not necessarily the OSC8 ID.
const link_id_: ?u16 = if (cell.hyperlink)
self.page.lookupHyperlink(cell)
else
null;
// If our hyperlink IDs match (even null) then we have
// identical hyperlink state and we do nothing.
if (current_hyperlink_id == link_id_) break :hyperlink;
// If our prior hyperlink ID was non-null, we need to
// close it because the ID has changed.
if (current_hyperlink_id != null) {
try self.formatHyperlinkClose(writer);
current_hyperlink_id = null;
}
// Set our current hyperlink ID
const link_id = link_id_ orelse break :hyperlink;
current_hyperlink_id = link_id;
// Emit the opening hyperlink tag
const uri = uri: {
const link = self.page.hyperlink_set.get(
self.page.memory,
link_id,
);
break :uri link.uri.offset.ptr(self.page.memory)[0..link.uri.len];
};
try self.formatHyperlinkOpen(
writer,
uri,
);
// If we have a point map, we map the hyperlink to
// this cell.
if (self.point_map) |*map| {
var discarding: std.Io.Writer.Discarding = .init(&.{});
try self.formatHyperlinkOpen(
&discarding.writer,
uri,
);
for (0..discarding.count) |_| map.map.append(map.alloc, .{
.x = x,
.y = y,
}) catch return error.WriteFailed;
}
}
switch (cell.content_tag) {
// We combine codepoint and graphemes because both have
// shared style handling. We use comptime to dup it.
@@ -1266,6 +1328,9 @@ pub const PageFormatter = struct {
// If the style is non-default, we need to close our style tag.
if (!style.default()) try self.formatStyleClose(writer);
// Close any open hyperlink for HTML output
if (current_hyperlink_id != null) try self.formatHyperlinkClose(writer);
// Close the monospace wrapper for HTML output
if (self.opts.emit == .html) {
const closing = "</div>";
@@ -1415,6 +1480,8 @@ pub const PageFormatter = struct {
};
}
/// Write a string with HTML escaping. Used for escaping href attributes
/// and other HTML attribute values.
fn formatStyleOpen(
self: PageFormatter,
writer: *std.Io.Writer,
@@ -1465,6 +1532,49 @@ pub const PageFormatter = struct {
);
}
}
fn formatHyperlinkOpen(
self: PageFormatter,
writer: *std.Io.Writer,
uri: []const u8,
) std.Io.Writer.Error!void {
switch (self.opts.emit) {
.plain, .vt => unreachable,
// layout since we're primarily using it as a CSS wrapper.
.html => {
try writer.writeAll("<a href=\"");
for (uri) |byte| try self.writeCodepoint(
writer,
byte,
);
try writer.writeAll("\">");
},
}
}
fn formatHyperlinkClose(
self: PageFormatter,
writer: *std.Io.Writer,
) std.Io.Writer.Error!void {
const str: []const u8 = switch (self.opts.emit) {
.html => "</a>",
.plain, .vt => return,
};
try writer.writeAll(str);
if (self.point_map) |*m| {
assert(m.map.items.len > 0);
m.map.ensureUnusedCapacity(
m.alloc,
str.len,
) catch return error.WriteFailed;
m.map.appendNTimesAssumeCapacity(
m.map.items[m.map.items.len - 1],
str.len,
);
}
}
};
test "Page plain single line" {
@@ -5937,3 +6047,222 @@ test "Page VT background color on trailing blank cells" {
// This should be true but currently fails due to the bug
try testing.expect(has_red_bg_line1);
}
test "Page HTML with hyperlinks" {
const testing = std.testing;
const alloc = testing.allocator;
var builder: std.Io.Writer.Allocating = .init(alloc);
defer builder.deinit();
var t = try Terminal.init(alloc, .{
.cols = 80,
.rows = 24,
});
defer t.deinit(alloc);
var s = t.vtStream();
defer s.deinit();
// Start a hyperlink, write some text, end it
try s.nextSlice("\x1b]8;;https://example.com\x1b\\link text\x1b]8;;\x1b\\ normal");
const pages = &t.screens.active.pages;
const page = &pages.pages.last.?.data;
var formatter: PageFormatter = .init(page, .{ .emit = .html });
try formatter.format(&builder.writer);
const output = builder.writer.buffered();
try testing.expectEqualStrings(
"<div style=\"font-family: monospace; white-space: pre;\">" ++
"<a href=\"https://example.com\">link text</a> normal" ++
"</div>",
output,
);
}
test "Page HTML with multiple hyperlinks" {
const testing = std.testing;
const alloc = testing.allocator;
var builder: std.Io.Writer.Allocating = .init(alloc);
defer builder.deinit();
var t = try Terminal.init(alloc, .{
.cols = 80,
.rows = 24,
});
defer t.deinit(alloc);
var s = t.vtStream();
defer s.deinit();
// Two different hyperlinks
try s.nextSlice("\x1b]8;;https://first.com\x1b\\first\x1b]8;;\x1b\\ ");
try s.nextSlice("\x1b]8;;https://second.com\x1b\\second\x1b]8;;\x1b\\");
const pages = &t.screens.active.pages;
const page = &pages.pages.last.?.data;
var formatter: PageFormatter = .init(page, .{ .emit = .html });
try formatter.format(&builder.writer);
const output = builder.writer.buffered();
try testing.expectEqualStrings(
"<div style=\"font-family: monospace; white-space: pre;\">" ++
"<a href=\"https://first.com\">first</a>" ++
" " ++
"<a href=\"https://second.com\">second</a>" ++
"</div>",
output,
);
}
test "Page HTML with hyperlink escaping" {
const testing = std.testing;
const alloc = testing.allocator;
var builder: std.Io.Writer.Allocating = .init(alloc);
defer builder.deinit();
var t = try Terminal.init(alloc, .{
.cols = 80,
.rows = 24,
});
defer t.deinit(alloc);
var s = t.vtStream();
defer s.deinit();
// URL with special characters that need escaping
try s.nextSlice("\x1b]8;;https://example.com?a=1&b=2\x1b\\link\x1b]8;;\x1b\\");
const pages = &t.screens.active.pages;
const page = &pages.pages.last.?.data;
var formatter: PageFormatter = .init(page, .{ .emit = .html });
try formatter.format(&builder.writer);
const output = builder.writer.buffered();
try testing.expectEqualStrings(
"<div style=\"font-family: monospace; white-space: pre;\">" ++
"<a href=\"https://example.com?a=1&amp;b=2\">link</a>" ++
"</div>",
output,
);
}
test "Page HTML with styled hyperlink" {
const testing = std.testing;
const alloc = testing.allocator;
var builder: std.Io.Writer.Allocating = .init(alloc);
defer builder.deinit();
var t = try Terminal.init(alloc, .{
.cols = 80,
.rows = 24,
});
defer t.deinit(alloc);
var s = t.vtStream();
defer s.deinit();
// Bold hyperlink
try s.nextSlice("\x1b]8;;https://example.com\x1b\\\x1b[1mbold link\x1b[0m\x1b]8;;\x1b\\");
const pages = &t.screens.active.pages;
const page = &pages.pages.last.?.data;
var formatter: PageFormatter = .init(page, .{ .emit = .html });
try formatter.format(&builder.writer);
const output = builder.writer.buffered();
try testing.expectEqualStrings(
"<div style=\"font-family: monospace; white-space: pre;\">" ++
"<div style=\"display: inline;font-weight: bold;\">" ++
"<a href=\"https://example.com\">bold link</div></a>" ++
"</div>",
output,
);
}
test "Page HTML hyperlink closes style before anchor" {
const testing = std.testing;
const alloc = testing.allocator;
var builder: std.Io.Writer.Allocating = .init(alloc);
defer builder.deinit();
var t = try Terminal.init(alloc, .{
.cols = 80,
.rows = 24,
});
defer t.deinit(alloc);
var s = t.vtStream();
defer s.deinit();
// Styled hyperlink followed by plain text
try s.nextSlice("\x1b]8;;https://example.com\x1b\\\x1b[1mbold\x1b[0m plain");
const pages = &t.screens.active.pages;
const page = &pages.pages.last.?.data;
var formatter: PageFormatter = .init(page, .{ .emit = .html });
try formatter.format(&builder.writer);
const output = builder.writer.buffered();
try testing.expectEqualStrings(
"<div style=\"font-family: monospace; white-space: pre;\">" ++
"<div style=\"display: inline;font-weight: bold;\">" ++
"<a href=\"https://example.com\">bold</div> plain</a>" ++
"</div>",
output,
);
}
test "Page HTML hyperlink point map maps closing to previous cell" {
const testing = std.testing;
const alloc = testing.allocator;
var builder: std.Io.Writer.Allocating = .init(alloc);
defer builder.deinit();
var t = try Terminal.init(alloc, .{
.cols = 80,
.rows = 24,
});
defer t.deinit(alloc);
var s = t.vtStream();
defer s.deinit();
try s.nextSlice("\x1b]8;;https://example.com\x1b\\link\x1b]8;;\x1b\\ normal");
const pages = &t.screens.active.pages;
const page = &pages.pages.last.?.data;
var formatter: PageFormatter = .init(page, .{ .emit = .html });
var point_map: std.ArrayList(Coordinate) = .empty;
defer point_map.deinit(alloc);
formatter.point_map = .{ .alloc = alloc, .map = &point_map };
try formatter.format(&builder.writer);
const output = builder.writer.buffered();
const expected_output =
"<div style=\"font-family: monospace; white-space: pre;\">" ++
"<a href=\"https://example.com\">link</a> normal" ++
"</div>";
try testing.expectEqualStrings(expected_output, output);
try testing.expectEqual(expected_output.len, point_map.items.len);
// The </a> closing tag bytes should all map to the last cell of the link
const closing_idx = comptime std.mem.indexOf(u8, expected_output, "</a>").?;
const expected_coord = point_map.items[closing_idx - 1];
for (closing_idx..closing_idx + "</a>".len) |i| {
try testing.expectEqual(expected_coord, point_map.items[i]);
}
}

View File

@@ -566,7 +566,9 @@ pub const Config = struct {
working_directory: ?[]const u8 = null,
resources_dir: ?[]const u8,
term: []const u8,
linux_cgroup: Command.LinuxCgroup = Command.linux_cgroup_default,
rt_pre_exec_info: Command.RtPreExecInfo,
rt_post_fork_info: Command.RtPostForkInfo,
};
const Subprocess = struct {
@@ -584,7 +586,9 @@ const Subprocess = struct {
screen_size: renderer.ScreenSize,
pty: ?Pty = null,
process: ?Process = null,
linux_cgroup: Command.LinuxCgroup = Command.linux_cgroup_default,
rt_pre_exec_info: Command.RtPreExecInfo,
rt_post_fork_info: Command.RtPostForkInfo,
/// Union that represents the running process type.
const Process = union(enum) {
@@ -851,21 +855,14 @@ const Subprocess = struct {
// https://github.com/ghostty-org/ghostty/discussions/7769
if (cwd) |pwd| try env.put("PWD", pwd);
// If we have a cgroup, then we copy that into our arena so the
// memory remains valid when we start.
const linux_cgroup: Command.LinuxCgroup = cgroup: {
const default = Command.linux_cgroup_default;
if (comptime builtin.os.tag != .linux) break :cgroup default;
const path = cfg.linux_cgroup orelse break :cgroup default;
break :cgroup try alloc.dupe(u8, path);
};
return .{
.arena = arena,
.env = env,
.cwd = cwd,
.args = args,
.linux_cgroup = linux_cgroup,
.rt_pre_exec_info = cfg.rt_pre_exec_info,
.rt_post_fork_info = cfg.rt_post_fork_info,
// Should be initialized with initTerminal call.
.grid_size = .{},
@@ -1014,17 +1011,27 @@ const Subprocess = struct {
.stdout = if (builtin.os.tag == .windows) null else .{ .handle = pty.slave },
.stderr = if (builtin.os.tag == .windows) null else .{ .handle = pty.slave },
.pseudo_console = if (builtin.os.tag == .windows) pty.pseudo_console else {},
.pre_exec = if (builtin.os.tag == .windows) null else (struct {
fn callback(cmd: *Command) void {
const sp = cmd.getData(Subprocess) orelse unreachable;
sp.childPreExec() catch |err| log.err(
"error initializing child: {}",
.{err},
);
}
}).callback,
.os_pre_exec = switch (comptime builtin.os.tag) {
.windows => null,
else => f: {
const f = struct {
fn callback(cmd: *Command) ?u8 {
const sp = cmd.getData(Subprocess) orelse unreachable;
sp.childPreExec() catch |err| log.err(
"error initializing child: {}",
.{err},
);
return null;
}
};
break :f f.callback;
},
},
.rt_pre_exec = if (comptime @hasDecl(apprt.runtime, "pre_exec")) apprt.runtime.pre_exec.preExec else null,
.rt_pre_exec_info = self.rt_pre_exec_info,
.rt_post_fork = if (comptime @hasDecl(apprt.runtime, "post_fork")) apprt.runtime.post_fork.postFork else null,
.rt_post_fork_info = self.rt_post_fork_info,
.data = self,
.linux_cgroup = self.linux_cgroup,
};
cmd.start(alloc) catch |err| {
@@ -1046,9 +1053,6 @@ const Subprocess = struct {
log.warn("error killing command during cleanup err={}", .{err});
};
log.info("started subcommand path={s} pid={?}", .{ self.args[0], cmd.pid });
if (comptime builtin.os.tag == .linux) {
log.info("subcommand cgroup={s}", .{self.linux_cgroup orelse "-"});
}
self.process = .{ .fork_exec = cmd };
return switch (builtin.os.tag) {

View File

@@ -175,8 +175,28 @@ pub const DerivedConfig = struct {
errdefer arena.deinit();
const alloc = arena.allocator();
const palette: terminalpkg.color.Palette = palette: {
if (config.@"palette-generate") generate: {
if (config.palette.mask.findFirstSet() == null) {
// If the user didn't set any values manually, then
// we're using the default palette and we don't need
// to apply the generation code to it.
break :generate;
}
break :palette terminalpkg.color.generate256Color(
config.palette.value,
config.palette.mask,
config.background.toTerminalRGB(),
config.foreground.toTerminalRGB(),
);
}
break :palette config.palette.value;
};
return .{
.palette = config.palette.value,
.palette = palette,
.image_storage_limit = config.@"image-storage-limit",
.cursor_style = config.@"cursor-style",
.cursor_blink = config.@"cursor-style-blink",