Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

- macOS 14+
- Xcode 26+ (Swift 6 and the macOS 26 SDK)
- [Zig](https://ziglang.org/) (install via `brew install zig`)
- [Zig](https://ziglang.org/), the exact version printed by `scripts/required-zig-version.sh` (currently 0.16.0). `brew install zig` may install a different version, which fails the GhosttyKit build
- [Rust](https://rustup.rs/) with Cargo for the shared application core. Add
`aarch64-apple-darwin` and `x86_64-apple-darwin` targets when building a
universal macOS release. The Xcode build compiles and links the core automatically.
Expand All @@ -28,7 +28,7 @@ in [docs/windows-testing.md](docs/windows-testing.md).
```

This will:
- Initialize git submodules (ghostty, homebrew-programa)
- Initialize git submodules (ghostty)
- Build the GhosttyKit.xcframework from source
- Create the necessary symlinks

Expand Down
47 changes: 43 additions & 4 deletions Sources/WindowPaneChromePortal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,7 @@ private final class NativePaneTabBarView: PaneChromeDragBackgroundView {
select: { [weak descriptor] in descriptor?.onSelect(tab.id) },
close: { [weak descriptor] in descriptor?.onClose(tab.id) },
context: { [weak descriptor] action in descriptor?.onContextAction(tab.id, action) },
applyTabColor: { [weak descriptor] hex in descriptor?.onApplyTabColor(tab.id, hex) },
dragData: { [weak descriptor] in descriptor?.dragPasteboardData(tab.id) },
dragState: { [weak descriptor] active in descriptor?.onDragStateChanged(tab.id, active) }
)
Expand Down Expand Up @@ -952,11 +953,16 @@ private final class NativeGlassTabPillView: NSView, NSDraggingSource {
select: @escaping () -> Void,
close: @escaping () -> Void,
context: @escaping (TabContextAction) -> Void,
applyTabColor: @escaping (String) -> Void,
dragData: @escaping () -> Data?,
dragState: @escaping (Bool) -> Void
) {
control.update(tab, select: select, close: close, context: context, dragData: dragData, dragState: dragState)
control.update(
tab, select: select, close: close, context: context,
applyTabColor: applyTabColor, dragData: dragData, dragState: dragState
)
isSelected = tab.isSelected
customTint = tab.customColorHex.flatMap { NSColor(hex: $0) }
control.onHoverChanged = { [weak self] hovering in
self?.isHovered = hovering
self?.applySurfaceState()
Expand All @@ -966,6 +972,8 @@ private final class NativeGlassTabPillView: NSView, NSDraggingSource {

private var isSelected = false
private var isHovered = false
/// User-chosen tab color; when set it replaces the neutral surface tone.
private var customTint: NSColor?

/// Maps-style states: the selected pill reads as a solid lit surface,
/// hover lifts a quiet pill slightly, unselected pills stay quiet glass.
Expand All @@ -976,7 +984,12 @@ private final class NativeGlassTabPillView: NSView, NSDraggingSource {
// control capsules share it; quiet pills stay untinted. labelColor@0.12
Comment thread
arzafran marked this conversation as resolved.
Resolved
// is a white lift in dark mode, but the same alpha in light mode is a
// black wash that reads muddy/pressed — halve it there.
if isSelected {
if let customTint {
// Same low-opacity wash as the SwiftUI tab bar (0.2), lifted a little
// for hover/selected so those states stay distinguishable.
let alpha: CGFloat = isSelected ? 0.4 : (isHovered ? 0.3 : 0.2)
glass.tintColor = customTint.withAlphaComponent(alpha)
} else if isSelected {
glass.tintColor = WindowGlassEffect.surfaceLiftTint(for: effectiveAppearance)
} else if isHovered {
glass.tintColor = WindowGlassEffect.surfaceLiftTint(for: effectiveAppearance, hover: true)
Expand Down Expand Up @@ -1008,6 +1021,7 @@ private final class NativeTabPillControl: NSControl, NSMenuDelegate, NSDraggingS
private var selectAction: (() -> Void)?
private var closeAction: (() -> Void)?
private var contextAction: ((TabContextAction) -> Void)?
private var applyTabColorAction: ((String) -> Void)?
private var dragData: (() -> Data?)?
private var dragState: ((Bool) -> Void)?
var onHoverChanged: ((Bool) -> Void)?
Expand Down Expand Up @@ -1070,13 +1084,15 @@ private final class NativeTabPillControl: NSControl, NSMenuDelegate, NSDraggingS
select: @escaping () -> Void,
close: @escaping () -> Void,
context: @escaping (TabContextAction) -> Void,
applyTabColor: @escaping (String) -> Void,
dragData: @escaping () -> Data?,
dragState: @escaping (Bool) -> Void
) {
self.tab = tab
selectAction = select
closeAction = close
contextAction = context
applyTabColorAction = applyTabColor
self.dragData = dragData
self.dragState = dragState
titleField.stringValue = tab.title
Expand Down Expand Up @@ -1187,8 +1203,12 @@ private final class NativeTabPillControl: NSControl, NSMenuDelegate, NSDraggingS

override func rightMouseDown(with event: NSEvent) {
guard let tab else { return }
NSMenu.popUpContextMenu(makeMenu(from: tab.menuItems), with: event, for: self)
}

private func makeMenu(from items: [BonsplitPaneChromeMenuItem]) -> NSMenu {
let menu = NSMenu()
for item in tab.menuItems {
for item in items {
switch item {
case .separator:
menu.addItem(.separator())
Expand All @@ -1198,9 +1218,28 @@ private final class NativeTabPillControl: NSControl, NSMenuDelegate, NSDraggingS
menuItem.representedObject = ActionBox(action)
menuItem.isEnabled = enabled
menu.addItem(menuItem)
case .submenu(let title, let children):
let menuItem = NSMenuItem(title: title, action: nil, keyEquivalent: "")
menuItem.submenu = makeMenu(from: children)
menu.addItem(menuItem)
case .tabColor(let title, let hex, let swatch):
let menuItem = NSMenuItem(title: title, action: #selector(applyTabColor(_:)), keyEquivalent: "")
Comment thread
arzafran marked this conversation as resolved.
Resolved
menuItem.target = self
menuItem.representedObject = hex
menuItem.image = NSImage(size: NSSize(width: 12, height: 12), flipped: false) { rect in
swatch.setFill()
NSBezierPath(ovalIn: rect.insetBy(dx: 0.5, dy: 0.5)).fill()
return true
}
menu.addItem(menuItem)
}
}
NSMenu.popUpContextMenu(menu, with: event, for: self)
return menu
}

@objc private func applyTabColor(_ sender: NSMenuItem) {
guard let hex = sender.representedObject as? String else { return }
applyTabColorAction?(hex)
}

@objc private func closePressed() { closeAction?() }
Expand Down
28 changes: 18 additions & 10 deletions programaTests/WorkspaceUnitTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5364,22 +5364,30 @@ final class SessionPanelSnapshotTabColorTests: XCTestCase {
)
}

func testCustomColorHexRoundTripsThroughEncodeDecode() throws {
let original = makeSnapshot(customColorHex: "#1565C0")
let data = try JSONEncoder().encode(original)
let decoded = try JSONDecoder().decode(SessionPanelSnapshot.self, from: data)
XCTAssertEqual(decoded.customColorHex, "#1565C0")
XCTAssertEqual(decoded.id, original.id)
@MainActor
func testWorkspaceTabColorSurvivesSessionRestore() throws {
let workspace = Workspace()
let panelId = try XCTUnwrap(workspace.focusedPanelId)
workspace.setPanelColor(panelId: panelId, hex: "#1565C0")

let snapshot = workspace.sessionSnapshot(includeScrollback: false)
XCTAssertEqual(snapshot.panels.first(where: { $0.id == panelId })?.customColorHex, "#1565C0")

let restored = Workspace()
restored.restoreSessionSnapshot(snapshot)

let restoredPanelId = try XCTUnwrap(restored.focusedPanelId)
XCTAssertEqual(restored.panelColorHexes[restoredPanelId], "#1565C0")
let restoredTabId = try XCTUnwrap(restored.surfaceIdFromPanelId(restoredPanelId))
XCTAssertEqual(restored.bonsplitController.tab(restoredTabId)?.customColorHex, "#1565C0")
}

func testMissingCustomColorHexKeyDecodesToNil() throws {
// Simulates a pre-tab-color session snapshot written before this field existed.
let legacySnapshot = makeSnapshot(customColorHex: nil)
var data = try JSONEncoder().encode(legacySnapshot)
var object = try JSONSerialization.jsonObject(with: data) as! [String: Any]
let data = try JSONEncoder().encode(legacySnapshot)
let object = try XCTUnwrap(try JSONSerialization.jsonObject(with: data) as? [String: Any])
XCTAssertNil(object["customColorHex"], "legacy fixture must not already contain the key")
object.removeValue(forKey: "customColorHex")
data = try JSONSerialization.data(withJSONObject: object)

let decoded = try JSONDecoder().decode(SessionPanelSnapshot.self, from: data)
XCTAssertNil(decoded.customColorHex)
Expand Down
16 changes: 16 additions & 0 deletions vendor/bonsplit/Sources/Bonsplit/Internal/Models/TabItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,22 @@ struct TabItem: Identifiable, Hashable, Codable {
self.customColorHex = customColorHex
}

init(from tab: Tab) {
self.init(
id: tab.id.id,
title: tab.title,
hasCustomTitle: tab.hasCustomTitle,
icon: tab.icon,
iconImageData: tab.iconImageData,
kind: tab.kind,
isDirty: tab.isDirty,
showsNotificationBadge: tab.showsNotificationBadge,
isLoading: tab.isLoading,
isPinned: tab.isPinned,
customColorHex: tab.customColorHex
)
}

func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@ private struct BonsplitPaneChromeAnchor: NSViewRepresentable {
isPinned: tab.isPinned,
isDirty: tab.isDirty,
showsNotificationBadge: tab.showsNotificationBadge,
customColorHex: tab.customColorHex,
accessibilityValue: accessibilityValue(for: tab),
menuItems: menuItems(for: tab, at: index, splitController: splitController)
)
Expand Down Expand Up @@ -343,6 +344,10 @@ private struct BonsplitPaneChromeAnchor: NSViewRepresentable {
guard let pane, let bonsplitController else { return }
bonsplitController.requestTabContextAction(action, for: tabID, inPane: pane.id)
},
onApplyTabColor: { [weak pane, weak bonsplitController] tabID, hex in
guard let pane, let bonsplitController else { return }
bonsplitController.requestTabColor(hex, for: tabID, inPane: pane.id)
},
dragPasteboardData: { [weak pane, weak splitController] tabID in
guard let pane, let splitController,
let tab = pane.tabs.first(where: { $0.id == tabID.id }) else { return nil }
Expand Down Expand Up @@ -486,9 +491,40 @@ private struct BonsplitPaneChromeAnchor: NSViewRepresentable {
action: tab.showsNotificationBadge ? .markAsRead : .markAsUnread,
isEnabled: true
))
items.append(.separator)
items.append(tabColorSubmenu(for: tab))
return items
}

private func tabColorSubmenu(for tab: TabItem) -> BonsplitPaneChromeMenuItem {
var items: [BonsplitPaneChromeMenuItem] = []
if tab.customColorHex != nil {
items.append(.action(
title: localized("contextMenu.clearColor", defaultValue: "Clear Color"),
action: .clearTabColor,
isEnabled: true
))
}
items.append(.action(
title: localized("contextMenu.chooseCustomColor", defaultValue: "Choose Custom Color…"),
action: .chooseCustomTabColor,
isEnabled: true
))
let palette = bonsplitController?.tabColorPaletteProvider?() ?? []
if !palette.isEmpty { items.append(.separator) }
for swatch in palette {
items.append(.tabColor(title: swatch.name, hex: swatch.hex, swatch: swatch.swatchColor))
}
return .submenu(
title: localized("contextMenu.tabColor", defaultValue: "Tab Color"),
items: items
)
}

private func localized(_ key: String, defaultValue: String) -> String {
Bundle.module.localizedString(forKey: key, value: defaultValue, table: nil)
}

private func localized(_ value: String) -> String {
Bundle.module.localizedString(forKey: value, value: value, table: nil)
}
Expand Down
26 changes: 2 additions & 24 deletions vendor/bonsplit/Sources/Bonsplit/Public/BonsplitController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -423,18 +423,7 @@ public final class BonsplitController {

let internalTab: TabItem?
if let tab {
internalTab = TabItem(
id: tab.id.id,
title: tab.title,
hasCustomTitle: tab.hasCustomTitle,
icon: tab.icon,
iconImageData: tab.iconImageData,
kind: tab.kind,
isDirty: tab.isDirty,
showsNotificationBadge: tab.showsNotificationBadge,
isLoading: tab.isLoading,
isPinned: tab.isPinned
)
internalTab = TabItem(from: tab)
} else {
internalTab = nil
}
Expand Down Expand Up @@ -485,18 +474,7 @@ public final class BonsplitController {
return nil
}

let internalTab = TabItem(
id: tab.id.id,
title: tab.title,
hasCustomTitle: tab.hasCustomTitle,
icon: tab.icon,
iconImageData: tab.iconImageData,
kind: tab.kind,
isDirty: tab.isDirty,
showsNotificationBadge: tab.showsNotificationBadge,
isLoading: tab.isLoading,
isPinned: tab.isPinned
)
let internalTab = TabItem(from: tab)

// Perform split with insertion side.
internalController.splitPaneWithTab(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import Foundation
public enum BonsplitPaneChromeMenuItem {
case separator
case action(title: String, action: TabContextAction, isEnabled: Bool)
/// A nested menu (used for the Tab Color submenu).
indirect case submenu(title: String, items: [BonsplitPaneChromeMenuItem])
/// A palette swatch that applies `hex` to the tab through `onApplyTabColor`.
case tabColor(title: String, hex: String, swatch: NSColor)
}

/// Read-only tab state consumed by the native pane chrome renderer.
Expand All @@ -17,6 +21,8 @@ public struct BonsplitPaneChromeTabDescriptor {
public let isPinned: Bool
public let isDirty: Bool
public let showsNotificationBadge: Bool
/// Optional "#RRGGBB" tint for the tab.
public let customColorHex: String?
public let accessibilityValue: String
public let menuItems: [BonsplitPaneChromeMenuItem]

Expand All @@ -29,6 +35,7 @@ public struct BonsplitPaneChromeTabDescriptor {
isPinned: Bool,
isDirty: Bool,
showsNotificationBadge: Bool,
customColorHex: String? = nil,
accessibilityValue: String,
menuItems: [BonsplitPaneChromeMenuItem]
) {
Expand All @@ -40,6 +47,7 @@ public struct BonsplitPaneChromeTabDescriptor {
self.isPinned = isPinned
self.isDirty = isDirty
self.showsNotificationBadge = showsNotificationBadge
self.customColorHex = customColorHex
self.accessibilityValue = accessibilityValue
self.menuItems = menuItems
}
Expand All @@ -57,6 +65,7 @@ public final class BonsplitPaneChromeDescriptor {
public let onSelect: (TabID) -> Void
public let onClose: (TabID) -> Void
public let onContextAction: (TabID, TabContextAction) -> Void
public let onApplyTabColor: (TabID, String) -> Void
public let dragPasteboardData: (TabID) -> Data?
public let onDragStateChanged: (TabID, Bool) -> Void
public let validatedDropIndex: (Int) -> Int?
Expand All @@ -77,6 +86,7 @@ public final class BonsplitPaneChromeDescriptor {
onSelect: @escaping (TabID) -> Void,
onClose: @escaping (TabID) -> Void,
onContextAction: @escaping (TabID, TabContextAction) -> Void,
onApplyTabColor: @escaping (TabID, String) -> Void = { _, _ in },
dragPasteboardData: @escaping (TabID) -> Data?,
onDragStateChanged: @escaping (TabID, Bool) -> Void,
validatedDropIndex: @escaping (Int) -> Int?,
Expand All @@ -96,6 +106,7 @@ public final class BonsplitPaneChromeDescriptor {
self.onSelect = onSelect
self.onClose = onClose
self.onContextAction = onContextAction
self.onApplyTabColor = onApplyTabColor
self.dragPasteboardData = dragPasteboardData
self.onDragStateChanged = onDragStateChanged
self.validatedDropIndex = validatedDropIndex
Expand Down
Loading