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
8 changes: 7 additions & 1 deletion Sources/Bonsplit/Internal/Models/TabItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ struct TabItem: Identifiable, Hashable, Codable {
var isAudioPlaying: Bool
var isPinned: Bool
var showsRemoteIndicator: Bool
var presence: TabPresence?

init(
id: UUID = UUID(),
Expand All @@ -46,7 +47,8 @@ struct TabItem: Identifiable, Hashable, Codable {
isAudioMuted: Bool = false,
isAudioPlaying: Bool = false,
isPinned: Bool = false,
showsRemoteIndicator: Bool = false
showsRemoteIndicator: Bool = false,
presence: TabPresence? = nil
) {
self.id = id
self.title = title
Expand All @@ -62,6 +64,7 @@ struct TabItem: Identifiable, Hashable, Codable {
self.isAudioPlaying = isAudioPlaying
self.isPinned = isPinned
self.showsRemoteIndicator = showsRemoteIndicator
self.presence = presence
}

func hash(into hasher: inout Hasher) {
Expand All @@ -87,6 +90,7 @@ struct TabItem: Identifiable, Hashable, Codable {
case isAudioPlaying
case isPinned
case showsRemoteIndicator
case presence
}

init(from decoder: Decoder) throws {
Expand All @@ -105,6 +109,7 @@ struct TabItem: Identifiable, Hashable, Codable {
self.isAudioPlaying = try c.decodeIfPresent(Bool.self, forKey: .isAudioPlaying) ?? false
self.isPinned = try c.decodeIfPresent(Bool.self, forKey: .isPinned) ?? false
self.showsRemoteIndicator = try c.decodeIfPresent(Bool.self, forKey: .showsRemoteIndicator) ?? false
self.presence = try c.decodeIfPresent(TabPresence.self, forKey: .presence)
}

func encode(to encoder: Encoder) throws {
Expand All @@ -123,6 +128,7 @@ struct TabItem: Identifiable, Hashable, Codable {
try c.encode(isAudioPlaying, forKey: .isAudioPlaying)
try c.encode(isPinned, forKey: .isPinned)
try c.encode(showsRemoteIndicator, forKey: .showsRemoteIndicator)
try c.encodeIfPresent(presence, forKey: .presence)
}
}

Expand Down
56 changes: 56 additions & 0 deletions Sources/Bonsplit/Internal/Styling/TabBarColors.swift
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,62 @@ enum TabBarColors {
return tone.withAlphaComponent(alpha)
}

/// Colors of the shared-terminal presence accessory on a tab.
struct PresenceColors {
/// The surface under the accessory (tab fill or bar).
let surface: NSColor
let fill: NSColor
let glyph: NSColor
let text: NSColor
let line: NSColor
}

/// The accessory draws on the tab's own fill when selected (unless the
/// host shares one backdrop), else on the bar, in the tab bar's text
/// color. Every color is dynamic, so system fallbacks resolve in the
/// appearance that draws them.
static func presenceColors(
for appearance: BonsplitConfiguration.Appearance,
isSelected: Bool
) -> PresenceColors {
let surface = nsColorPresenceSurface(for: appearance, isSelected: isSelected)
let foreground = nsColorActiveText(for: appearance)
func color(_ role: BonsplitContrastPalette.Role) -> NSColor {
BonsplitContrastPalette.dynamicColor(role, background: surface, foreground: foreground)
}
return PresenceColors(
surface: surface,
fill: color(.fill),
glyph: color(.glyph),
text: color(.text),
line: color(.line)
)
}

private static func nsColorPresenceSurface(
for appearance: BonsplitConfiguration.Appearance,
isSelected: Bool
) -> NSColor {
guard isSelected, !appearance.usesSharedBackdrop else {
return nsColorBarBackground(for: appearance)
}
guard let custom = tabBarBackgroundColor(for: appearance) else {
return .controlBackgroundColor
}
return custom.isBonsplitLightColor
? custom.bonsplitDarken(by: 0.065)
: custom.bonsplitLighten(by: 0.12)
}

/// The divider between split panes: `dividerHex` when set, otherwise the chrome separator.
static func nsColorSplitDivider(for appearance: BonsplitConfiguration.Appearance) -> NSColor {
if let value = appearance.chromeColors.dividerHex,
let divider = NSColor(bonsplitHex: value) {
return divider
}
return nsColorSeparator(for: appearance)
}

static var dropIndicator: Color {
Color.accentColor
}
Expand Down
4 changes: 2 additions & 2 deletions Sources/Bonsplit/Internal/Views/SplitContainerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ struct SplitContainerView<Content: View, EmptyContent: View>: NSViewRepresentabl
#else
let splitView = ThemedSplitView()
#endif
splitView.customDividerColor = TabBarColors.nsColorSeparator(for: appearance)
splitView.customDividerColor = TabBarColors.nsColorSplitDivider(for: appearance)
splitView.customDividerThickness = TabBarMetrics.resolvedDividerThickness(appearance.dividerThickness)
splitView.customDividerHitExpansion = appearance.dividerHitExpansion
splitView.stampedInternalController = controller
Expand Down Expand Up @@ -513,7 +513,7 @@ struct SplitContainerView<Content: View, EmptyContent: View>: NSViewRepresentabl
splitView.wantsLayer = true
splitView.layer?.backgroundColor = NSColor.clear.cgColor
splitView.layer?.isOpaque = false
(splitView as? ThemedSplitView)?.customDividerColor = TabBarColors.nsColorSeparator(for: appearance)
(splitView as? ThemedSplitView)?.customDividerColor = TabBarColors.nsColorSplitDivider(for: appearance)
let resolvedThickness = TabBarMetrics.resolvedDividerThickness(appearance.dividerThickness)
let dividerThicknessChanged = (splitView as? ThemedSplitView)?.customDividerThickness != resolvedThickness
(splitView as? ThemedSplitView)?.customDividerThickness = resolvedThickness
Expand Down
9 changes: 7 additions & 2 deletions Sources/Bonsplit/Internal/Views/TabBarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -723,6 +723,8 @@ struct TabContextMenuState {
let hasSplits: Bool
let shortcuts: [TabContextAction: KeyboardShortcut]
var canDisconnectRemote: Bool = false
/// Shared-terminal presence; non-nil adds the terminal-size menu section.
var presence: TabPresence?

var canMarkAsUnread: Bool {
!isUnread
Expand Down Expand Up @@ -750,7 +752,8 @@ struct TabContextMenuState {
isFullWidthTabMode: Bool = false,
hasSplits: Bool,
shortcuts: [TabContextAction: KeyboardShortcut],
canDisconnectRemote: Bool = false
canDisconnectRemote: Bool = false,
presence: TabPresence? = nil
) {
self.isPinned = isPinned
self.isUnread = isUnread
Expand All @@ -770,6 +773,7 @@ struct TabContextMenuState {
self.hasSplits = hasSplits
self.shortcuts = shortcuts
self.canDisconnectRemote = canDisconnectRemote
self.presence = presence
}

@MainActor
Expand Down Expand Up @@ -811,7 +815,8 @@ struct TabContextMenuState {
isFullWidthTabMode: pane.isFullWidthTabMode,
hasSplits: splitViewController.rootNode.allPaneIds.count > 1,
shortcuts: controller.contextMenuShortcuts,
canDisconnectRemote: controller.tabContextDisconnectRemoteAvailabilityProvider?(TabID(id: tab.id), pane.id) ?? false
canDisconnectRemote: controller.tabContextDisconnectRemoteAvailabilityProvider?(TabID(id: tab.id), pane.id) ?? false,
presence: tab.presence
)
}
}
Expand Down
137 changes: 136 additions & 1 deletion Sources/Bonsplit/Internal/Views/TabItemView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,7 @@ struct TabItemView: View {
@State private var closeButtonPointerInside = false
@State private var isZoomHovered = false
@State private var isAudioHovered = false
@State private var isPresenceHovered = false
@State private var showGlobeFallback = true
@State private var globeFallbackScheduler = TabIconFallbackScheduler()
@State private var lastIsLoadingObserved = false
Expand Down Expand Up @@ -339,6 +340,7 @@ struct TabItemView: View {
// Icon-only pinned tabs always size to their fixed compact width.
.fixedSize(horizontal: isIconOnlyPinned || !fillsWidth, vertical: false)
.background(tabBackground.saturation(saturation))
.background(TabPopoverAnchorView(tabId: tab.id, kind: .tabItem))
.tabControlShortcutHintVisibilityAnimation(value: showsShortcutHint)
.contentShape(Rectangle().inset(by: -BonsplitTabItemHitTesting.horizontalSlop))
// Middle click to close (macOS convention).
Expand Down Expand Up @@ -401,6 +403,58 @@ struct TabItemView: View {
.tabGeometryDebugOnChange(of: tab.isLoading) { newValue in
debugRecordIsLoadingStateChange(newValue)
}
.overlayPreferenceValue(TabPresenceAccessoryBoundsKey.self) { anchor in
presenceAccessoryOverlay(anchor)
}
}

private func presenceAccessory(_ presence: TabPresence) -> some View {
TabPresenceAccessoryView(
presence: presence,
colors: TabBarColors.presenceColors(for: appearance, isSelected: isSelected),
isHovered: isPresenceHovered,
hoverBackground: TabBarColors.hoveredTabBackground(for: appearance)
)
}

/// The clickable presence accessory, placed over the space the title row
/// reserved for it. Toggles the host's size panel, which anchors to it.
@ViewBuilder
private func presenceAccessoryOverlay(_ anchor: Anchor<CGRect>?) -> some View {
if let anchor, let presence = tab.presence, presence.showsAccessory {
GeometryReader { proxy in
let rect = proxy[anchor]
Button {
onContextAction(.toggleSizePanel)
} label: {
presenceAccessory(presence)
}
.buttonStyle(.plain)
.background(TabPopoverAnchorView(tabId: tab.id, kind: .presenceAccessory))
.onHover { hovering in
withTransaction(Transaction(animation: nil)) {
isPresenceHovered = hovering
}
}
.saturation(saturation)
.safeHelp(presence.accessibilityLabel)
.accessibilityElement(children: .ignore)
.accessibilityAddTraits(.isButton)
.accessibilityLabel(presence.accessibilityLabel)
.accessibilityHint(
Bundle.module.localizedString(
forKey: "tabPresence.toggleSizePanel",
value: "Shows or hides the terminal size panel",
table: nil
)
)
.accessibilityIdentifier("tabPresenceAccessory")
.accessibilityAction { onContextAction(.toggleSizePanel) }
.tabBarButtonAnimationsDisabled()
.frame(width: rect.width, height: rect.height)
.offset(x: rect.minX, y: rect.minY)
}
}
}

/// Whether this tab renders in the compact icon-only style reserved for pinned
Expand Down Expand Up @@ -553,6 +607,17 @@ struct TabItemView: View {
debugRecordGeometry(which: "title", frame: frame)
}

if let presence = tab.presence, presence.showsAccessory {
// Reserves the accessory's space in the title row. The live
// button is drawn by `presenceAccessoryOverlay`, outside the
// tab's combined accessibility element, so it stays its own
// AXButton.
presenceAccessory(presence)
.hidden()
.accessibilityHidden(true)
.anchorPreference(key: TabPresenceAccessoryBoundsKey.self, value: .bounds) { $0 }
}

if tab.showsRemoteIndicator {
Image(systemName: "network")
.font(.system(size: accessoryFontSize, weight: .semibold))
Expand Down Expand Up @@ -1409,6 +1474,10 @@ enum TabContextMenuBuilder {
)
}

if let presence = state.presence {
addTerminalSizeSection(presence: presence, state: state, target: target, to: menu)
}

menu.addItem(.separator())

addAction(
Expand Down Expand Up @@ -1601,6 +1670,64 @@ enum TabContextMenuBuilder {
return menu
}

/// Adds the shared-terminal size actions for a tab that has presence:
/// Size to My Window, a Terminal Size submenu, and Disconnect Others…
/// while anyone else is attached.
private static func addTerminalSizeSection(
presence: TabPresence,
state: TabContextMenuState,
target: TabContextMenuActionTarget,
to menu: NSMenu
) {
menu.addItem(.separator())
addAction(
title: localized("tabContext.sizeToMyWindow", defaultValue: "Size to My Window"),
action: .sizeToMyWindow,
state: state,
target: target,
to: menu
)
let sizeTitle = localized("tabContext.terminalSizeHeader", defaultValue: "Terminal Size")
let sizeItem = NSMenuItem(title: sizeTitle, action: nil, keyEquivalent: "")
let sizeMenu = NSMenu(title: sizeTitle)
for mode in TabPresence.SizeMode.allCases {
addAction(
title: sizeModeTitle(mode),
action: .sizeMode(mode),
state: state,
target: target,
to: sizeMenu,
stateValue: presence.sizeMode == mode ? .on : .off
)
}
sizeItem.submenu = sizeMenu
menu.addItem(sizeItem)
if presence.canDisconnectOthers {
addAction(
title: localized("tabContext.disconnectOthers", defaultValue: "Disconnect Others…"),
action: .disconnectOtherClients,
state: state,
target: target,
to: menu
)
}
}

private static func sizeModeTitle(_ mode: TabPresence.SizeMode) -> String {
switch mode {
case .latest:
return localized("tabContext.sizeMode.followLatest", defaultValue: "Follow Latest")
case .smallest:
return localized("tabContext.sizeMode.fitEveryone", defaultValue: "Fit Everyone")
case .largest:
return localized("tabContext.sizeMode.largest", defaultValue: "Largest Window")
case .priority:
return localized("tabContext.sizeMode.priority", defaultValue: "Priority List…")
case .fixed:
return localized("tabContext.sizeMode.fixed", defaultValue: "Fixed Size…")
}
}

static func updateForkConversationAvailability(
_ availability: TabContextForkConversationAvailability,
in menu: NSMenu
Expand Down Expand Up @@ -1791,7 +1918,15 @@ enum TabContextMenuBuilder {
.markAsUnread,
.toggleZoom,
.toggleFullWidthTab,
.disconnectRemote:
.disconnectRemote,
.sizeToMyWindow,
.sizeModeLatest,
.sizeModeSmallest,
.sizeModeLargest,
.sizeModePriority,
.sizeModeFixed,
.toggleSizePanel,
.disconnectOtherClients:
assertionFailure("Non-fork action cannot be the default fork destination: \(action)")
return localized(
"tabContext.forkConversation.default.right",
Expand Down
Loading
Loading