From b77fa9a25551a35e3dfd693a2dd406c25d0cd9a6 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:52:33 -0700 Subject: [PATCH 1/9] Add shared-terminal presence accessory and size menu to tabs Tabs gain an optional TabPresence: an avatar stack with an owner ring, the grid size, and a dashed-box glyph when this device does not match. Clicking it sends .showSizePanel. The context menu adds Size to My Window, Don't Resize from This Mac, a Terminal Size mode radio group, Show Size Panel and Disconnect Other Clients when presence is set. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Bonsplit/Internal/Models/TabItem.swift | 8 +- .../Bonsplit/Internal/Views/TabBarView.swift | 9 +- .../Bonsplit/Internal/Views/TabItemView.swift | 125 +++++++++++++++++- .../Views/TabPresenceAccessoryView.swift | 97 ++++++++++++++ .../Bonsplit/Public/BonsplitController.swift | 19 ++- Sources/Bonsplit/Public/Types/Tab.swift | 7 +- .../Public/Types/TabContextAction.swift | 44 ++++++ .../Bonsplit/Public/Types/TabPresence.swift | 95 +++++++++++++ .../Resources/en.lproj/Localizable.strings | 13 ++ .../Resources/ja.lproj/Localizable.strings | 13 ++ Tests/BonsplitTests/TabPresenceTests.swift | 106 +++++++++++++++ 11 files changed, 526 insertions(+), 10 deletions(-) create mode 100644 Sources/Bonsplit/Internal/Views/TabPresenceAccessoryView.swift create mode 100644 Sources/Bonsplit/Public/Types/TabPresence.swift create mode 100644 Tests/BonsplitTests/TabPresenceTests.swift diff --git a/Sources/Bonsplit/Internal/Models/TabItem.swift b/Sources/Bonsplit/Internal/Models/TabItem.swift index 898c87b4..be7f011e 100644 --- a/Sources/Bonsplit/Internal/Models/TabItem.swift +++ b/Sources/Bonsplit/Internal/Models/TabItem.swift @@ -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(), @@ -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 @@ -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) { @@ -87,6 +90,7 @@ struct TabItem: Identifiable, Hashable, Codable { case isAudioPlaying case isPinned case showsRemoteIndicator + case presence } init(from decoder: Decoder) throws { @@ -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 { @@ -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) } } diff --git a/Sources/Bonsplit/Internal/Views/TabBarView.swift b/Sources/Bonsplit/Internal/Views/TabBarView.swift index 63c2ff36..402309fd 100644 --- a/Sources/Bonsplit/Internal/Views/TabBarView.swift +++ b/Sources/Bonsplit/Internal/Views/TabBarView.swift @@ -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 @@ -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 @@ -770,6 +773,7 @@ struct TabContextMenuState { self.hasSplits = hasSplits self.shortcuts = shortcuts self.canDisconnectRemote = canDisconnectRemote + self.presence = presence } @MainActor @@ -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 ) } } diff --git a/Sources/Bonsplit/Internal/Views/TabItemView.swift b/Sources/Bonsplit/Internal/Views/TabItemView.swift index 18f0d376..3e09d13c 100644 --- a/Sources/Bonsplit/Internal/Views/TabItemView.swift +++ b/Sources/Bonsplit/Internal/Views/TabItemView.swift @@ -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 @@ -553,6 +554,41 @@ struct TabItemView: View { debugRecordGeometry(which: "title", frame: frame) } + if let presence = tab.presence { + Button { + onContextAction(.showSizePanel) + } label: { + TabPresenceAccessoryView( + presence: presence, + borderColor: isSelected + ? TabBarColors.activeTabBackground(for: appearance) + : TabBarColors.barBackground(for: appearance), + textColor: isSelected + ? TabBarColors.activeText(for: appearance) + : TabBarColors.inactiveText(for: appearance), + isHovered: isPresenceHovered, + hoverBackground: TabBarColors.hoveredTabBackground(for: appearance) + ) + } + .buttonStyle(.plain) + .onHover { hovering in + withTransaction(Transaction(animation: nil)) { + isPresenceHovered = hovering + } + } + .saturation(saturation) + .safeHelp(presence.accessibilityLabel) + .accessibilityLabel(presence.accessibilityLabel) + .accessibilityHint( + Bundle.module.localizedString( + forKey: "tabPresence.showSizePanel", + value: "Shows the terminal size panel", + table: nil + ) + ) + .tabBarButtonAnimationsDisabled() + } + if tab.showsRemoteIndicator { Image(systemName: "network") .font(.system(size: accessoryFontSize, weight: .semibold)) @@ -1409,6 +1445,10 @@ enum TabContextMenuBuilder { ) } + if let presence = state.presence { + addTerminalSizeSection(presence: presence, state: state, target: target, to: menu) + } + menu.addItem(.separator()) addAction( @@ -1601,6 +1641,80 @@ enum TabContextMenuBuilder { return menu } + /// Adds the shared-terminal size actions for a tab that has presence. + 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 + ) + addAction( + title: localized("tabContext.dontResizeFromThisMac", defaultValue: "Don't Resize from This Mac"), + action: .toggleSizeCountsFromThisDevice, + state: state, + target: target, + to: menu, + stateValue: presence.countsFromThisDevice ? .off : .on + ) + menu.addItem(.separator()) + let header = NSMenuItem( + title: localized("tabContext.terminalSizeHeader", defaultValue: "Terminal Size"), + action: nil, + keyEquivalent: "" + ) + header.isEnabled = false + menu.addItem(header) + for mode in TabPresence.SizeMode.allCases { + addAction( + title: sizeModeTitle(mode), + action: .sizeMode(mode), + state: state, + target: target, + to: menu, + stateValue: presence.sizeMode == mode ? .on : .off + ) + } + menu.addItem(.separator()) + addAction( + title: localized("tabContext.showSizePanel", defaultValue: "Show Size Panel…"), + action: .showSizePanel, + state: state, + target: target, + to: menu + ) + addAction( + title: localized("tabContext.disconnectOtherClients", defaultValue: "Disconnect Other Clients…"), + action: .disconnectOtherClients, + enabled: presence.canDisconnectOthers, + state: state, + target: target, + to: menu + ) + } + + private static func sizeModeTitle(_ mode: TabPresence.SizeMode) -> String { + switch mode { + case .latest: + return localized("tabContext.sizeMode.latest", defaultValue: "Follow Latest Input") + case .smallest: + return localized("tabContext.sizeMode.smallest", defaultValue: "Fit Everyone (Smallest)") + 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 @@ -1791,7 +1905,16 @@ enum TabContextMenuBuilder { .markAsUnread, .toggleZoom, .toggleFullWidthTab, - .disconnectRemote: + .disconnectRemote, + .sizeToMyWindow, + .toggleSizeCountsFromThisDevice, + .sizeModeLatest, + .sizeModeSmallest, + .sizeModeLargest, + .sizeModePriority, + .sizeModeFixed, + .showSizePanel, + .disconnectOtherClients: assertionFailure("Non-fork action cannot be the default fork destination: \(action)") return localized( "tabContext.forkConversation.default.right", diff --git a/Sources/Bonsplit/Internal/Views/TabPresenceAccessoryView.swift b/Sources/Bonsplit/Internal/Views/TabPresenceAccessoryView.swift new file mode 100644 index 00000000..6ed874b2 --- /dev/null +++ b/Sources/Bonsplit/Internal/Views/TabPresenceAccessoryView.swift @@ -0,0 +1,97 @@ +import SwiftUI + +/// The shared-terminal presence accessory drawn after a tab title: an +/// overlapping avatar stack with an owner ring, the grid size, and a dashed-box +/// glyph when this device's window does not match the grid. +struct TabPresenceAccessoryView: View { + let presence: TabPresence + /// Fill behind the tab, used for the avatar separation border. + let borderColor: Color + let textColor: Color + let isHovered: Bool + let hoverBackground: Color + + private static let maxAvatars = 3 + private static let avatarSize: CGFloat = 14 + private static let overlap: CGFloat = 4 + static let mismatchColor = Color(tabPresenceHex: "#E9B44C") ?? .orange + + var body: some View { + HStack(spacing: 4) { + avatarStack + Text(presence.gridLabel) + .font(.system(size: 10).monospacedDigit()) + .foregroundStyle(textColor.opacity(0.78)) + .lineLimit(1) + .fixedSize() + if presence.viewerMismatch { + RoundedRectangle(cornerRadius: 2, style: .continuous) + .stroke(Self.mismatchColor, style: StrokeStyle(lineWidth: 1.2, dash: [2, 1.5])) + .frame(width: 11, height: 9) + .frame(width: 11, height: 11) + } + } + .padding(.horizontal, 3) + .padding(.vertical, 1) + .background( + Capsule(style: .continuous) + .fill(isHovered ? hoverBackground : .clear) + ) + .contentShape(Capsule(style: .continuous)) + } + + @ViewBuilder + private var avatarStack: some View { + let shown = Array(presence.participants.prefix(Self.maxAvatars)) + let extra = presence.participants.count - shown.count + HStack(spacing: -Self.overlap) { + ForEach(shown) { participant in + avatar(participant) + } + if extra > 0 { + Text("+\(extra)") + .font(.system(size: 8, weight: .semibold).monospacedDigit()) + .foregroundStyle(textColor.opacity(0.78)) + .padding(.leading, Self.overlap + 2) + .fixedSize() + } + } + } + + private func avatar(_ participant: TabPresence.Participant) -> some View { + let color = Color(tabPresenceHex: participant.colorHex) ?? .gray + return ZStack { + Circle() + .fill(color) + Text(participant.initials) + .font(.system(size: 7, weight: .semibold)) + .foregroundStyle(Color(red: 0.055, green: 0.07, blue: 0.094)) + .lineLimit(1) + .minimumScaleFactor(0.6) + } + .frame(width: Self.avatarSize, height: Self.avatarSize) + .overlay(Circle().stroke(borderColor, lineWidth: 1.5)) + .overlay { + if participant.isOwner { + Circle() + .inset(by: -3) + .stroke(color, lineWidth: 1.5) + } + } + .padding(participant.isOwner ? 3 : 0) + } +} + +extension Color { + /// Parses `#RRGGBB` (or `RRGGBB`); nil for anything else. + init?(tabPresenceHex hex: String) { + var text = hex.trimmingCharacters(in: .whitespacesAndNewlines) + if text.hasPrefix("#") { text.removeFirst() } + guard text.count == 6, let value = UInt32(text, radix: 16) else { return nil } + self.init( + red: Double((value >> 16) & 0xFF) / 255, + green: Double((value >> 8) & 0xFF) / 255, + blue: Double(value & 0xFF) / 255 + ) + } +} diff --git a/Sources/Bonsplit/Public/BonsplitController.swift b/Sources/Bonsplit/Public/BonsplitController.swift index 5190f793..8f3c8f75 100644 --- a/Sources/Bonsplit/Public/BonsplitController.swift +++ b/Sources/Bonsplit/Public/BonsplitController.swift @@ -327,6 +327,7 @@ public final class BonsplitController { /// - isAudioMuted: New browser-audio mute state (pass nil to keep current) /// - isAudioPlaying: New audible-audio state (pass nil to keep current) /// - isPinned: New pinned state (pass nil to keep current) + /// - presence: New shared-terminal presence (pass nil to keep current, pass .some(nil) to remove) public func updateTab( _ tabId: TabID, title: String? = nil, @@ -341,7 +342,8 @@ public final class BonsplitController { isAudioMuted: Bool? = nil, isAudioPlaying: Bool? = nil, isPinned: Bool? = nil, - showsRemoteIndicator: Bool? = nil + showsRemoteIndicator: Bool? = nil, + presence: TabPresence?? = nil ) { guard let (pane, tabIndex) = findTabInternal(tabId) else { return } let currentTab = pane.tabs[tabIndex] @@ -358,7 +360,8 @@ public final class BonsplitController { isAudioMuted.map { currentTab.isAudioMuted != $0 } ?? false || isAudioPlaying.map { currentTab.isAudioPlaying != $0 } ?? false || isPinned.map { currentTab.isPinned != $0 } ?? false || - showsRemoteIndicator.map { currentTab.showsRemoteIndicator != $0 } ?? false + showsRemoteIndicator.map { currentTab.showsRemoteIndicator != $0 } ?? false || + presence.map { currentTab.presence != $0 } ?? false guard didChange else { return } if let title = title { @@ -400,6 +403,9 @@ public final class BonsplitController { if let showsRemoteIndicator = showsRemoteIndicator { pane.tabs[tabIndex].showsRemoteIndicator = showsRemoteIndicator } + if let presence = presence { + pane.tabs[tabIndex].presence = presence + } } /// Close a tab by ID @@ -574,7 +580,8 @@ public final class BonsplitController { isAudioMuted: tab.isAudioMuted, isAudioPlaying: tab.isAudioPlaying, isPinned: tab.isPinned, - showsRemoteIndicator: tab.showsRemoteIndicator + showsRemoteIndicator: tab.showsRemoteIndicator, + presence: tab.presence ) let newPaneId = internalController.splitRootWithTab( orientation: orientation, @@ -626,7 +633,8 @@ public final class BonsplitController { isAudioMuted: tab.isAudioMuted, isAudioPlaying: tab.isAudioPlaying, isPinned: tab.isPinned, - showsRemoteIndicator: tab.showsRemoteIndicator + showsRemoteIndicator: tab.showsRemoteIndicator, + presence: tab.presence ) } else { internalTab = nil @@ -695,7 +703,8 @@ public final class BonsplitController { isAudioMuted: tab.isAudioMuted, isAudioPlaying: tab.isAudioPlaying, isPinned: tab.isPinned, - showsRemoteIndicator: tab.showsRemoteIndicator + showsRemoteIndicator: tab.showsRemoteIndicator, + presence: tab.presence ) // Perform split with insertion side. diff --git a/Sources/Bonsplit/Public/Types/Tab.swift b/Sources/Bonsplit/Public/Types/Tab.swift index 8f11c6e2..10525ecc 100644 --- a/Sources/Bonsplit/Public/Types/Tab.swift +++ b/Sources/Bonsplit/Public/Types/Tab.swift @@ -26,6 +26,8 @@ public struct Tab: Identifiable, Hashable, Sendable { public let isPinned: Bool /// Whether the tab should show a remote-connection indicator (library consumer-defined meaning, e.g. SSH). public let showsRemoteIndicator: Bool + /// Shared-terminal presence (attached viewers and grid size); nil hides it. + public let presence: TabPresence? public init( id: TabID = TabID(), @@ -41,7 +43,8 @@ public struct Tab: Identifiable, Hashable, Sendable { isAudioMuted: Bool = false, isAudioPlaying: Bool = false, isPinned: Bool = false, - showsRemoteIndicator: Bool = false + showsRemoteIndicator: Bool = false, + presence: TabPresence? = nil ) { self.id = id self.title = title @@ -57,6 +60,7 @@ public struct Tab: Identifiable, Hashable, Sendable { self.isAudioPlaying = isAudioPlaying self.isPinned = isPinned self.showsRemoteIndicator = showsRemoteIndicator + self.presence = presence } internal init(from tabItem: TabItem) { @@ -74,5 +78,6 @@ public struct Tab: Identifiable, Hashable, Sendable { self.isAudioPlaying = tabItem.isAudioPlaying self.isPinned = tabItem.isPinned self.showsRemoteIndicator = tabItem.showsRemoteIndicator + self.presence = tabItem.presence } } diff --git a/Sources/Bonsplit/Public/Types/TabContextAction.swift b/Sources/Bonsplit/Public/Types/TabContextAction.swift index e213f05a..e7256e05 100644 --- a/Sources/Bonsplit/Public/Types/TabContextAction.swift +++ b/Sources/Bonsplit/Public/Types/TabContextAction.swift @@ -37,9 +37,53 @@ public enum TabContextAction: String, CaseIterable, Sendable { case forkConversationBottom case forkConversationNewTab case forkConversationNewWorkspace + /// Makes this device's window set the shared terminal's size. + case sizeToMyWindow + /// Toggles whether this device counts toward the shared terminal's size. + case toggleSizeCountsFromThisDevice + /// Sets the shared terminal's sizing mode to ``TabPresence/SizeMode/latest``. + case sizeModeLatest + /// Sets the sizing mode to ``TabPresence/SizeMode/smallest``. + case sizeModeSmallest + /// Sets the sizing mode to ``TabPresence/SizeMode/largest``. + case sizeModeLargest + /// Sets the sizing mode to ``TabPresence/SizeMode/priority``. + case sizeModePriority + /// Sets the sizing mode to ``TabPresence/SizeMode/fixed``. + case sizeModeFixed + /// Opens the host's terminal size panel. + case showSizePanel + /// Asks the host to disconnect every other client of the terminal. + case disconnectOtherClients public static let defaultForkConversationDestination: TabContextAction = .forkConversationRight + /// The context action that selects a sizing mode. + /// + /// - Parameter mode: The sizing mode. + /// - Returns: The matching `sizeMode*` action. + public static func sizeMode(_ mode: TabPresence.SizeMode) -> TabContextAction { + switch mode { + case .latest: return .sizeModeLatest + case .smallest: return .sizeModeSmallest + case .largest: return .sizeModeLargest + case .priority: return .sizeModePriority + case .fixed: return .sizeModeFixed + } + } + + /// The sizing mode this action selects, or nil for any other action. + public var sizeMode: TabPresence.SizeMode? { + switch self { + case .sizeModeLatest: return .latest + case .sizeModeSmallest: return .smallest + case .sizeModeLargest: return .largest + case .sizeModePriority: return .priority + case .sizeModeFixed: return .fixed + default: return nil + } + } + public var isForkConversationDestination: Bool { switch self { case .forkConversationRight, diff --git a/Sources/Bonsplit/Public/Types/TabPresence.swift b/Sources/Bonsplit/Public/Types/TabPresence.swift new file mode 100644 index 00000000..37c19768 --- /dev/null +++ b/Sources/Bonsplit/Public/Types/TabPresence.swift @@ -0,0 +1,95 @@ +import Foundation + +/// Who is viewing a shared terminal tab and what size its grid has. +/// +/// The host app fills this from its terminal sizing state. Bonsplit renders it +/// as a presence accessory after the tab title and adds the terminal-size +/// section to the tab context menu. A `nil` presence hides both. +public struct TabPresence: Hashable, Sendable, Codable { + /// One attached person or device shown in the tab's avatar stack. + public struct Participant: Hashable, Sendable, Codable, Identifiable { + /// Host-scoped participant id. + public var id: String + /// One or two letters drawn inside the avatar. + public var initials: String + /// Avatar fill color as `#RRGGBB`. + public var colorHex: String + /// Whether this participant sets the terminal size (drawn with a ring). + public var isOwner: Bool + /// Name read by VoiceOver and shown in help text. + public var accessibilityName: String + + /// Creates a participant row. + /// + /// - Parameters: + /// - id: Host-scoped participant id. + /// - initials: Letters drawn inside the avatar. + /// - colorHex: Avatar color as `#RRGGBB`. + /// - isOwner: Whether this participant sets the terminal size. + /// - accessibilityName: Spoken name. + public init(id: String, initials: String, colorHex: String, isOwner: Bool, accessibilityName: String) { + self.id = id + self.initials = initials + self.colorHex = colorHex + self.isOwner = isOwner + self.accessibilityName = accessibilityName + } + } + + /// How the host picks the terminal grid. Mirrors the host's sizing modes. + public enum SizeMode: String, Hashable, Sendable, Codable, CaseIterable { + /// The participant with the newest input sets the size. + case latest + /// Component-wise minimum over counting participants. + case smallest + /// Component-wise maximum over counting participants. + case largest + /// The first attached participant in a priority list sets the size. + case priority + /// One fixed grid regardless of who is attached. + case fixed + } + + /// Attached participants, in display order. + public var participants: [Participant] + /// Grid label drawn after the avatars, for example `118×38`. + public var gridLabel: String + /// Whether this device's window does not match the grid (draws a dashed-box glyph). + public var viewerMismatch: Bool + /// The current sizing mode, checked in the context menu. + public var sizeMode: SizeMode + /// Whether this device counts toward the size. `false` checks "Don't Resize from This Mac". + public var countsFromThisDevice: Bool + /// Whether "Disconnect Other Clients…" is enabled. + public var canDisconnectOthers: Bool + /// Label for the accessory button and its help tooltip. + public var accessibilityLabel: String + + /// Creates a presence snapshot. + /// + /// - Parameters: + /// - participants: Attached participants. + /// - gridLabel: Grid label such as `118×38`. + /// - viewerMismatch: Whether this device's window differs from the grid. + /// - sizeMode: Current sizing mode. + /// - countsFromThisDevice: Whether this device counts toward size. + /// - canDisconnectOthers: Whether other clients can be disconnected. + /// - accessibilityLabel: Accessory label and tooltip. + public init( + participants: [Participant], + gridLabel: String, + viewerMismatch: Bool, + sizeMode: SizeMode, + countsFromThisDevice: Bool, + canDisconnectOthers: Bool, + accessibilityLabel: String + ) { + self.participants = participants + self.gridLabel = gridLabel + self.viewerMismatch = viewerMismatch + self.sizeMode = sizeMode + self.countsFromThisDevice = countsFromThisDevice + self.canDisconnectOthers = canDisconnectOthers + self.accessibilityLabel = accessibilityLabel + } +} diff --git a/Sources/Bonsplit/Resources/en.lproj/Localizable.strings b/Sources/Bonsplit/Resources/en.lproj/Localizable.strings index 0a3806dc..820e8a99 100644 --- a/Sources/Bonsplit/Resources/en.lproj/Localizable.strings +++ b/Sources/Bonsplit/Resources/en.lproj/Localizable.strings @@ -42,3 +42,16 @@ "tabContext.disconnectRemote" = "Disconnect SSH"; "tabContext.remoteConnectedAccessibility" = "Connected over SSH"; "tab.close.accessibilityLabel" = "Close Tab"; + +/* Shared terminal sizing */ +"tabContext.sizeToMyWindow" = "Size to My Window"; +"tabContext.dontResizeFromThisMac" = "Don't Resize from This Mac"; +"tabContext.terminalSizeHeader" = "Terminal Size"; +"tabContext.sizeMode.latest" = "Follow Latest Input"; +"tabContext.sizeMode.smallest" = "Fit Everyone (Smallest)"; +"tabContext.sizeMode.largest" = "Largest Window"; +"tabContext.sizeMode.priority" = "Priority List…"; +"tabContext.sizeMode.fixed" = "Fixed Size…"; +"tabContext.showSizePanel" = "Show Size Panel…"; +"tabContext.disconnectOtherClients" = "Disconnect Other Clients…"; +"tabPresence.showSizePanel" = "Shows the terminal size panel"; diff --git a/Sources/Bonsplit/Resources/ja.lproj/Localizable.strings b/Sources/Bonsplit/Resources/ja.lproj/Localizable.strings index 6f3979be..1b8c3c67 100644 --- a/Sources/Bonsplit/Resources/ja.lproj/Localizable.strings +++ b/Sources/Bonsplit/Resources/ja.lproj/Localizable.strings @@ -42,3 +42,16 @@ "tabContext.disconnectRemote" = "SSH接続を解除"; "tabContext.remoteConnectedAccessibility" = "SSH接続中"; "tab.close.accessibilityLabel" = "タブを閉じる"; + +/* Shared terminal sizing */ +"tabContext.sizeToMyWindow" = "ウィンドウにサイズを合わせる"; +"tabContext.dontResizeFromThisMac" = "このMacからはサイズを変更しない"; +"tabContext.terminalSizeHeader" = "ターミナルサイズ"; +"tabContext.sizeMode.latest" = "最新の入力に合わせる"; +"tabContext.sizeMode.smallest" = "全員に合わせる(最小)"; +"tabContext.sizeMode.largest" = "最大のウィンドウ"; +"tabContext.sizeMode.priority" = "優先リスト…"; +"tabContext.sizeMode.fixed" = "固定サイズ…"; +"tabContext.showSizePanel" = "サイズパネルを表示…"; +"tabContext.disconnectOtherClients" = "他のクライアントを切断…"; +"tabPresence.showSizePanel" = "ターミナルサイズパネルを表示します"; diff --git a/Tests/BonsplitTests/TabPresenceTests.swift b/Tests/BonsplitTests/TabPresenceTests.swift new file mode 100644 index 00000000..9c975af0 --- /dev/null +++ b/Tests/BonsplitTests/TabPresenceTests.swift @@ -0,0 +1,106 @@ +import XCTest +@testable import Bonsplit +import AppKit +import SwiftUI + +final class TabPresenceTests: XCTestCase { + private func samplePresence(mode: TabPresence.SizeMode = .priority, counts: Bool = false, canDisconnect: Bool = false) -> TabPresence { + TabPresence( + participants: [ + .init(id: "c3", initials: "MO", colorHex: "#3CC2B0", isOwner: true, accessibilityName: "Maya Ortiz"), + .init(id: "mobile:1", initials: "DV", colorHex: "#EBA946", isOwner: false, accessibilityName: "Dev"), + ], + gridLabel: "118×38", + viewerMismatch: true, + sizeMode: mode, + countsFromThisDevice: counts, + canDisconnectOthers: canDisconnect, + accessibilityLabel: "118 × 38, Maya Ortiz sets the size" + ) + } + + func testTabItemCodableRoundTripsPresence() throws { + let presence = samplePresence() + let item = TabItem(title: "zsh", presence: presence) + let decoded = try JSONDecoder().decode(TabItem.self, from: JSONEncoder().encode(item)) + XCTAssertEqual(decoded.presence, presence) + + let legacy = try JSONDecoder().decode(TabItem.self, from: JSONEncoder().encode(TabItem(title: "zsh"))) + XCTAssertNil(legacy.presence) + } + + @MainActor + func testUpdateTabSetsKeepsAndClearsPresence() throws { + let controller = BonsplitController() + let tabId = try XCTUnwrap(controller.createTab(title: "zsh")) + XCTAssertNil(controller.tab(tabId)?.presence) + + let presence = samplePresence() + controller.updateTab(tabId, presence: .some(presence)) + XCTAssertEqual(controller.tab(tabId)?.presence, presence) + + controller.updateTab(tabId, title: "renamed") + XCTAssertEqual(controller.tab(tabId)?.presence, presence) + + controller.updateTab(tabId, presence: .some(nil)) + XCTAssertNil(controller.tab(tabId)?.presence) + } + + func testSizeModeActionsRoundTrip() { + for mode in TabPresence.SizeMode.allCases { + XCTAssertEqual(TabContextAction.sizeMode(mode).sizeMode, mode) + } + XCTAssertNil(TabContextAction.showSizePanel.sizeMode) + } + + @MainActor + func testContextMenuAddsTerminalSizeSectionOnlyWithPresence() throws { + let target = TabContextMenuActionTarget() + var selected: TabContextAction? + target.onContextAction = { selected = $0 } + + func menu(presence: TabPresence?) -> NSMenu { + let state = TabContextMenuState( + isPinned: false, isUnread: false, isBrowser: false, isAudioMuted: false, + isTerminal: true, hasCustomTitle: false, canCloseToLeft: false, + canCloseToRight: false, canCloseOthers: false, canMoveToNewWorkspace: false, + canMoveToLeftPane: false, canMoveToRightPane: false, + forkConversationDefaultAction: .forkConversationRight, isZoomed: false, + hasSplits: false, shortcuts: [:], presence: presence + ) + let snapshot = TabContextMenuSnapshot( + tabId: UUID(), state: state, + moveDestinationsProvider: { [] }, + forkConversationAvailabilityProvider: { .hidden } + ) + return TabContextMenuBuilder.makeMenu(snapshot: snapshot, target: target) + } + + XCTAssertFalse(menu(presence: nil).items.contains { $0.title == "Size to My Window" }) + + let withPresence = menu(presence: samplePresence(mode: .priority, counts: false, canDisconnect: false)) + let titles = withPresence.items.map(\.title) + for title in ["Size to My Window", "Don't Resize from This Mac", "Terminal Size", + "Follow Latest Input", "Fit Everyone (Smallest)", "Largest Window", + "Priority List…", "Fixed Size…", "Show Size Panel…", "Disconnect Other Clients…"] { + XCTAssertTrue(titles.contains(title), "missing \(title)") + } + let dontResize = try XCTUnwrap(withPresence.items.first { $0.title == "Don't Resize from This Mac" }) + XCTAssertEqual(dontResize.state, .on) + XCTAssertEqual(withPresence.items.first { $0.title == "Priority List…" }?.state, .on) + XCTAssertEqual(withPresence.items.first { $0.title == "Follow Latest Input" }?.state, .off) + XCTAssertEqual(withPresence.items.first { $0.title == "Disconnect Other Clients…" }?.isEnabled, false) + XCTAssertEqual(withPresence.items.first { $0.title == "Terminal Size" }?.isEnabled, false) + + let largest = try XCTUnwrap(withPresence.items.first { $0.title == "Largest Window" }) + target.performContextAction(largest) + XCTAssertEqual(selected, .sizeModeLargest) + } + + func testHexColorParsing() { + XCTAssertNotNil(Color(tabPresenceHex: "#3CC2B0")) + XCTAssertNotNil(Color(tabPresenceHex: "D6C24A")) + XCTAssertNil(Color(tabPresenceHex: "#12345")) + XCTAssertNil(Color(tabPresenceHex: "zzzzzz")) + } +} From 99096d8ed28a864201da64a972e0a67395c6401b Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 28 Sep 2026 04:00:21 -0700 Subject: [PATCH 2/9] Make the terminal-size tab accessory avatar-only and anchor popovers to tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The presence accessory now shows only up to three avatars (owner first, with a ring) and +N; the grid label and mismatch glyph are gone. It sits outside the tab's combined accessibility element so it is its own AXButton, and clicking it sends .toggleSizePanel. Participants may be empty, which hides the accessory but keeps the context-menu section. BonsplitController.popoverAnchorView(for:) returns the accessory when it is on screen, else the tab item, so hosts can hang a popover from the tab. The context menu keeps Size to My Window, moves the modes into a Terminal Size submenu, and shows Disconnect Others… only when others are attached. Don't Resize from This Mac and Show Size Panel… are removed. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Bonsplit/Internal/Views/TabItemView.swift | 159 ++++++++++-------- .../Views/TabPopoverAnchorRegistry.swift | 117 +++++++++++++ .../Views/TabPresenceAccessoryView.swift | 70 ++++---- .../Public/Types/TabContextAction.swift | 7 +- .../Bonsplit/Public/Types/TabPresence.swift | 33 ++-- .../Resources/en.lproj/Localizable.strings | 10 +- .../Resources/ja.lproj/Localizable.strings | 10 +- Tests/BonsplitTests/TabPresenceTests.swift | 72 +++++--- 8 files changed, 309 insertions(+), 169 deletions(-) create mode 100644 Sources/Bonsplit/Internal/Views/TabPopoverAnchorRegistry.swift diff --git a/Sources/Bonsplit/Internal/Views/TabItemView.swift b/Sources/Bonsplit/Internal/Views/TabItemView.swift index 3e09d13c..32bb5f6a 100644 --- a/Sources/Bonsplit/Internal/Views/TabItemView.swift +++ b/Sources/Bonsplit/Internal/Views/TabItemView.swift @@ -340,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). @@ -402,6 +403,63 @@ 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, + borderColor: isSelected + ? TabBarColors.activeTabBackground(for: appearance) + : TabBarColors.barBackground(for: appearance), + textColor: isSelected + ? TabBarColors.activeText(for: appearance) + : TabBarColors.inactiveText(for: appearance), + 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?) -> 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 @@ -554,39 +612,15 @@ struct TabItemView: View { debugRecordGeometry(which: "title", frame: frame) } - if let presence = tab.presence { - Button { - onContextAction(.showSizePanel) - } label: { - TabPresenceAccessoryView( - presence: presence, - borderColor: isSelected - ? TabBarColors.activeTabBackground(for: appearance) - : TabBarColors.barBackground(for: appearance), - textColor: isSelected - ? TabBarColors.activeText(for: appearance) - : TabBarColors.inactiveText(for: appearance), - isHovered: isPresenceHovered, - hoverBackground: TabBarColors.hoveredTabBackground(for: appearance) - ) - } - .buttonStyle(.plain) - .onHover { hovering in - withTransaction(Transaction(animation: nil)) { - isPresenceHovered = hovering - } - } - .saturation(saturation) - .safeHelp(presence.accessibilityLabel) - .accessibilityLabel(presence.accessibilityLabel) - .accessibilityHint( - Bundle.module.localizedString( - forKey: "tabPresence.showSizePanel", - value: "Shows the terminal size panel", - table: nil - ) - ) - .tabBarButtonAnimationsDisabled() + 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 { @@ -1641,7 +1675,9 @@ enum TabContextMenuBuilder { return menu } - /// Adds the shared-terminal size actions for a tab that has presence. + /// 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, @@ -1656,56 +1692,38 @@ enum TabContextMenuBuilder { target: target, to: menu ) - addAction( - title: localized("tabContext.dontResizeFromThisMac", defaultValue: "Don't Resize from This Mac"), - action: .toggleSizeCountsFromThisDevice, - state: state, - target: target, - to: menu, - stateValue: presence.countsFromThisDevice ? .off : .on - ) - menu.addItem(.separator()) - let header = NSMenuItem( - title: localized("tabContext.terminalSizeHeader", defaultValue: "Terminal Size"), - action: nil, - keyEquivalent: "" - ) - header.isEnabled = false - menu.addItem(header) + 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: menu, + to: sizeMenu, stateValue: presence.sizeMode == mode ? .on : .off ) } - menu.addItem(.separator()) - addAction( - title: localized("tabContext.showSizePanel", defaultValue: "Show Size Panel…"), - action: .showSizePanel, - state: state, - target: target, - to: menu - ) - addAction( - title: localized("tabContext.disconnectOtherClients", defaultValue: "Disconnect Other Clients…"), - action: .disconnectOtherClients, - enabled: presence.canDisconnectOthers, - state: state, - target: target, - to: menu - ) + 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.latest", defaultValue: "Follow Latest Input") + return localized("tabContext.sizeMode.followLatest", defaultValue: "Follow Latest") case .smallest: - return localized("tabContext.sizeMode.smallest", defaultValue: "Fit Everyone (Smallest)") + return localized("tabContext.sizeMode.fitEveryone", defaultValue: "Fit Everyone") case .largest: return localized("tabContext.sizeMode.largest", defaultValue: "Largest Window") case .priority: @@ -1907,13 +1925,12 @@ enum TabContextMenuBuilder { .toggleFullWidthTab, .disconnectRemote, .sizeToMyWindow, - .toggleSizeCountsFromThisDevice, .sizeModeLatest, .sizeModeSmallest, .sizeModeLargest, .sizeModePriority, .sizeModeFixed, - .showSizePanel, + .toggleSizePanel, .disconnectOtherClients: assertionFailure("Non-fork action cannot be the default fork destination: \(action)") return localized( diff --git a/Sources/Bonsplit/Internal/Views/TabPopoverAnchorRegistry.swift b/Sources/Bonsplit/Internal/Views/TabPopoverAnchorRegistry.swift new file mode 100644 index 00000000..48cd0e16 --- /dev/null +++ b/Sources/Bonsplit/Internal/Views/TabPopoverAnchorRegistry.swift @@ -0,0 +1,117 @@ +import AppKit +import SwiftUI + +/// Laid-out AppKit views a host can anchor a popover to, per tab: the tab +/// item itself and, when shown, its presence accessory. +/// +/// Views register while they are in a window and drop out when they leave or +/// deallocate, so a lookup never returns a view from a torn-down strip. +@MainActor +enum TabPopoverAnchorRegistry { + enum Kind { + case tabItem + case presenceAccessory + } + + private static let tabItems = NSMapTable.strongToWeakObjects() + private static let accessories = NSMapTable.strongToWeakObjects() + + private static func table(_ kind: Kind) -> NSMapTable { + switch kind { + case .tabItem: tabItems + case .presenceAccessory: accessories + } + } + + static func register(_ view: NSView, tabId: UUID, kind: Kind) { + table(kind).setObject(view, forKey: tabId as NSUUID) + } + + static func unregister(_ view: NSView, tabId: UUID, kind: Kind) { + let table = table(kind) + guard table.object(forKey: tabId as NSUUID) === view else { return } + table.removeObject(forKey: tabId as NSUUID) + } + + /// The accessory when it is on screen, else the tab item, else nil. + static func anchorView(for tabId: UUID) -> NSView? { + for kind in [Kind.presenceAccessory, .tabItem] { + if let view = table(kind).object(forKey: tabId as NSUUID), isOnScreen(view) { + return view + } + } + return nil + } + + private static func isOnScreen(_ view: NSView) -> Bool { + guard view.window != nil, !view.bounds.isEmpty, !view.visibleRect.isEmpty else { return false } + var current: NSView? = view + while let candidate = current { + guard !candidate.isHidden, candidate.alphaValue > 0 else { return false } + current = candidate.superview + } + return true + } +} + +/// A zero-behavior AppKit view that registers itself as a tab's popover anchor. +struct TabPopoverAnchorView: NSViewRepresentable { + let tabId: UUID + let kind: TabPopoverAnchorRegistry.Kind + + func makeNSView(context: Context) -> AnchorNSView { + let view = AnchorNSView() + view.configure(tabId: tabId, kind: kind) + return view + } + + func updateNSView(_ nsView: AnchorNSView, context: Context) { + nsView.configure(tabId: tabId, kind: kind) + } + + final class AnchorNSView: NSView { + private var tabId: UUID? + private var kind: TabPopoverAnchorRegistry.Kind = .tabItem + + override var mouseDownCanMoveWindow: Bool { false } + override func hitTest(_ point: NSPoint) -> NSView? { nil } + override func isAccessibilityElement() -> Bool { false } + + func configure(tabId: UUID, kind: TabPopoverAnchorRegistry.Kind) { + if self.tabId != tabId || self.kind != kind { + unregister() + self.tabId = tabId + self.kind = kind + } + registerIfInWindow() + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if window == nil { unregister() } else { registerIfInWindow() } + } + + private func registerIfInWindow() { + guard window != nil, let tabId else { return } + TabPopoverAnchorRegistry.register(self, tabId: tabId, kind: kind) + } + + private func unregister() { + guard let tabId else { return } + TabPopoverAnchorRegistry.unregister(self, tabId: tabId, kind: kind) + } + } +} + +extension BonsplitController { + /// The view a popover about this tab should point at: the presence + /// accessory when it is shown, else the tab item. Nil when the tab is not + /// laid out in a window (hidden tab bar, scrolled out of the strip). + /// + /// - Parameter tabId: The tab. + /// - Returns: A view in the tab strip, or nil. + @MainActor + public func popoverAnchorView(for tabId: TabID) -> NSView? { + TabPopoverAnchorRegistry.anchorView(for: tabId.id) + } +} diff --git a/Sources/Bonsplit/Internal/Views/TabPresenceAccessoryView.swift b/Sources/Bonsplit/Internal/Views/TabPresenceAccessoryView.swift index 6ed874b2..1d737cf3 100644 --- a/Sources/Bonsplit/Internal/Views/TabPresenceAccessoryView.swift +++ b/Sources/Bonsplit/Internal/Views/TabPresenceAccessoryView.swift @@ -1,8 +1,7 @@ import SwiftUI -/// The shared-terminal presence accessory drawn after a tab title: an -/// overlapping avatar stack with an owner ring, the grid size, and a dashed-box -/// glyph when this device's window does not match the grid. +/// The shared-terminal presence accessory drawn after a tab title: up to +/// three overlapping avatars, a ring in the owner's color, and `+N` for the rest. struct TabPresenceAccessoryView: View { let presence: TabPresence /// Fill behind the tab, used for the avatar separation border. @@ -11,51 +10,35 @@ struct TabPresenceAccessoryView: View { let isHovered: Bool let hoverBackground: Color - private static let maxAvatars = 3 + static let maxAvatars = 3 private static let avatarSize: CGFloat = 14 private static let overlap: CGFloat = 4 - static let mismatchColor = Color(tabPresenceHex: "#E9B44C") ?? .orange + private static let ringWidth: CGFloat = 1.5 var body: some View { - HStack(spacing: 4) { - avatarStack - Text(presence.gridLabel) - .font(.system(size: 10).monospacedDigit()) - .foregroundStyle(textColor.opacity(0.78)) - .lineLimit(1) - .fixedSize() - if presence.viewerMismatch { - RoundedRectangle(cornerRadius: 2, style: .continuous) - .stroke(Self.mismatchColor, style: StrokeStyle(lineWidth: 1.2, dash: [2, 1.5])) - .frame(width: 11, height: 9) - .frame(width: 11, height: 11) - } - } - .padding(.horizontal, 3) - .padding(.vertical, 1) - .background( - Capsule(style: .continuous) - .fill(isHovered ? hoverBackground : .clear) - ) - .contentShape(Capsule(style: .continuous)) - } - - @ViewBuilder - private var avatarStack: some View { let shown = Array(presence.participants.prefix(Self.maxAvatars)) let extra = presence.participants.count - shown.count HStack(spacing: -Self.overlap) { - ForEach(shown) { participant in + // Later avatars sit under earlier ones so the owner (first) stays on top. + ForEach(Array(shown.enumerated()), id: \.element.id) { index, participant in avatar(participant) + .zIndex(Double(shown.count - index)) } if extra > 0 { - Text("+\(extra)") - .font(.system(size: 8, weight: .semibold).monospacedDigit()) + Text(verbatim: "+\(extra)") + .font(.system(size: 9, weight: .semibold).monospacedDigit()) .foregroundStyle(textColor.opacity(0.78)) - .padding(.leading, Self.overlap + 2) + .padding(.leading, Self.overlap + 3) .fixedSize() } } + .padding(.horizontal, 3) + .padding(.vertical, 1) + .background( + Capsule(style: .continuous) + .fill(isHovered ? hoverBackground : .clear) + ) + .contentShape(Capsule(style: .continuous)) } private func avatar(_ participant: TabPresence.Participant) -> some View { @@ -63,22 +46,22 @@ struct TabPresenceAccessoryView: View { return ZStack { Circle() .fill(color) - Text(participant.initials) + Text(verbatim: participant.initials) .font(.system(size: 7, weight: .semibold)) .foregroundStyle(Color(red: 0.055, green: 0.07, blue: 0.094)) .lineLimit(1) .minimumScaleFactor(0.6) } .frame(width: Self.avatarSize, height: Self.avatarSize) - .overlay(Circle().stroke(borderColor, lineWidth: 1.5)) + .overlay(Circle().stroke(borderColor, lineWidth: 1)) .overlay { if participant.isOwner { Circle() - .inset(by: -3) - .stroke(color, lineWidth: 1.5) + .inset(by: -(Self.ringWidth + 0.5)) + .stroke(color, lineWidth: Self.ringWidth) } } - .padding(participant.isOwner ? 3 : 0) + .padding(participant.isOwner ? Self.ringWidth + 0.5 : 0) } } @@ -95,3 +78,12 @@ extension Color { ) } } + +/// Where the title row reserved space for the presence accessory. +struct TabPresenceAccessoryBoundsKey: PreferenceKey { + static let defaultValue: Anchor? = nil + + static func reduce(value: inout Anchor?, nextValue: () -> Anchor?) { + value = value ?? nextValue() + } +} diff --git a/Sources/Bonsplit/Public/Types/TabContextAction.swift b/Sources/Bonsplit/Public/Types/TabContextAction.swift index e7256e05..bb6d1ca2 100644 --- a/Sources/Bonsplit/Public/Types/TabContextAction.swift +++ b/Sources/Bonsplit/Public/Types/TabContextAction.swift @@ -39,8 +39,6 @@ public enum TabContextAction: String, CaseIterable, Sendable { case forkConversationNewWorkspace /// Makes this device's window set the shared terminal's size. case sizeToMyWindow - /// Toggles whether this device counts toward the shared terminal's size. - case toggleSizeCountsFromThisDevice /// Sets the shared terminal's sizing mode to ``TabPresence/SizeMode/latest``. case sizeModeLatest /// Sets the sizing mode to ``TabPresence/SizeMode/smallest``. @@ -51,8 +49,9 @@ public enum TabContextAction: String, CaseIterable, Sendable { case sizeModePriority /// Sets the sizing mode to ``TabPresence/SizeMode/fixed``. case sizeModeFixed - /// Opens the host's terminal size panel. - case showSizePanel + /// Opens the host's terminal size panel, or closes it when it is already + /// open for this tab. Sent by the presence accessory. + case toggleSizePanel /// Asks the host to disconnect every other client of the terminal. case disconnectOtherClients diff --git a/Sources/Bonsplit/Public/Types/TabPresence.swift b/Sources/Bonsplit/Public/Types/TabPresence.swift index 37c19768..3325b89c 100644 --- a/Sources/Bonsplit/Public/Types/TabPresence.swift +++ b/Sources/Bonsplit/Public/Types/TabPresence.swift @@ -2,9 +2,9 @@ import Foundation /// Who is viewing a shared terminal tab and what size its grid has. /// -/// The host app fills this from its terminal sizing state. Bonsplit renders it -/// as a presence accessory after the tab title and adds the terminal-size -/// section to the tab context menu. A `nil` presence hides both. +/// The host app fills this from its terminal sizing state. Bonsplit renders the +/// participants as an avatar accessory after the tab title and adds the +/// terminal-size section to the tab context menu. A `nil` presence hides both. public struct TabPresence: Hashable, Sendable, Codable { /// One attached person or device shown in the tab's avatar stack. public struct Participant: Hashable, Sendable, Codable, Identifiable { @@ -50,45 +50,34 @@ public struct TabPresence: Hashable, Sendable, Codable { case fixed } - /// Attached participants, in display order. + /// Attached participants, owner first. Empty hides the tab accessory + /// while keeping the terminal-size context menu section. public var participants: [Participant] - /// Grid label drawn after the avatars, for example `118×38`. - public var gridLabel: String - /// Whether this device's window does not match the grid (draws a dashed-box glyph). - public var viewerMismatch: Bool /// The current sizing mode, checked in the context menu. public var sizeMode: SizeMode - /// Whether this device counts toward the size. `false` checks "Don't Resize from This Mac". - public var countsFromThisDevice: Bool - /// Whether "Disconnect Other Clients…" is enabled. + /// Whether "Disconnect Others…" appears in the context menu. public var canDisconnectOthers: Bool - /// Label for the accessory button and its help tooltip. + /// Label and tooltip for the accessory, e.g. `Size set by Maya's Mac · 118×38`. public var accessibilityLabel: String + /// Whether the tab draws the avatar accessory. + public var showsAccessory: Bool { !participants.isEmpty } + /// Creates a presence snapshot. /// /// - Parameters: - /// - participants: Attached participants. - /// - gridLabel: Grid label such as `118×38`. - /// - viewerMismatch: Whether this device's window differs from the grid. + /// - participants: Attached participants, owner first; empty hides the accessory. /// - sizeMode: Current sizing mode. - /// - countsFromThisDevice: Whether this device counts toward size. /// - canDisconnectOthers: Whether other clients can be disconnected. /// - accessibilityLabel: Accessory label and tooltip. public init( participants: [Participant], - gridLabel: String, - viewerMismatch: Bool, sizeMode: SizeMode, - countsFromThisDevice: Bool, canDisconnectOthers: Bool, accessibilityLabel: String ) { self.participants = participants - self.gridLabel = gridLabel - self.viewerMismatch = viewerMismatch self.sizeMode = sizeMode - self.countsFromThisDevice = countsFromThisDevice self.canDisconnectOthers = canDisconnectOthers self.accessibilityLabel = accessibilityLabel } diff --git a/Sources/Bonsplit/Resources/en.lproj/Localizable.strings b/Sources/Bonsplit/Resources/en.lproj/Localizable.strings index 820e8a99..445e70a5 100644 --- a/Sources/Bonsplit/Resources/en.lproj/Localizable.strings +++ b/Sources/Bonsplit/Resources/en.lproj/Localizable.strings @@ -45,13 +45,11 @@ /* Shared terminal sizing */ "tabContext.sizeToMyWindow" = "Size to My Window"; -"tabContext.dontResizeFromThisMac" = "Don't Resize from This Mac"; "tabContext.terminalSizeHeader" = "Terminal Size"; -"tabContext.sizeMode.latest" = "Follow Latest Input"; -"tabContext.sizeMode.smallest" = "Fit Everyone (Smallest)"; "tabContext.sizeMode.largest" = "Largest Window"; "tabContext.sizeMode.priority" = "Priority List…"; "tabContext.sizeMode.fixed" = "Fixed Size…"; -"tabContext.showSizePanel" = "Show Size Panel…"; -"tabContext.disconnectOtherClients" = "Disconnect Other Clients…"; -"tabPresence.showSizePanel" = "Shows the terminal size panel"; +"tabContext.disconnectOthers" = "Disconnect Others…"; +"tabContext.sizeMode.followLatest" = "Follow Latest"; +"tabContext.sizeMode.fitEveryone" = "Fit Everyone"; +"tabPresence.toggleSizePanel" = "Shows or hides the terminal size panel"; diff --git a/Sources/Bonsplit/Resources/ja.lproj/Localizable.strings b/Sources/Bonsplit/Resources/ja.lproj/Localizable.strings index 1b8c3c67..a3c1601e 100644 --- a/Sources/Bonsplit/Resources/ja.lproj/Localizable.strings +++ b/Sources/Bonsplit/Resources/ja.lproj/Localizable.strings @@ -45,13 +45,11 @@ /* Shared terminal sizing */ "tabContext.sizeToMyWindow" = "ウィンドウにサイズを合わせる"; -"tabContext.dontResizeFromThisMac" = "このMacからはサイズを変更しない"; "tabContext.terminalSizeHeader" = "ターミナルサイズ"; -"tabContext.sizeMode.latest" = "最新の入力に合わせる"; -"tabContext.sizeMode.smallest" = "全員に合わせる(最小)"; "tabContext.sizeMode.largest" = "最大のウィンドウ"; "tabContext.sizeMode.priority" = "優先リスト…"; "tabContext.sizeMode.fixed" = "固定サイズ…"; -"tabContext.showSizePanel" = "サイズパネルを表示…"; -"tabContext.disconnectOtherClients" = "他のクライアントを切断…"; -"tabPresence.showSizePanel" = "ターミナルサイズパネルを表示します"; +"tabContext.disconnectOthers" = "他の接続を切断…"; +"tabContext.sizeMode.followLatest" = "最新の入力に合わせる"; +"tabContext.sizeMode.fitEveryone" = "全員に合わせる"; +"tabPresence.toggleSizePanel" = "ターミナルサイズパネルを表示または非表示にします"; diff --git a/Tests/BonsplitTests/TabPresenceTests.swift b/Tests/BonsplitTests/TabPresenceTests.swift index 9c975af0..8ec25ffe 100644 --- a/Tests/BonsplitTests/TabPresenceTests.swift +++ b/Tests/BonsplitTests/TabPresenceTests.swift @@ -4,21 +4,23 @@ import AppKit import SwiftUI final class TabPresenceTests: XCTestCase { - private func samplePresence(mode: TabPresence.SizeMode = .priority, counts: Bool = false, canDisconnect: Bool = false) -> TabPresence { + private func samplePresence(mode: TabPresence.SizeMode = .priority, canDisconnect: Bool = false, alone: Bool = false) -> TabPresence { TabPresence( - participants: [ + participants: alone ? [] : [ .init(id: "c3", initials: "MO", colorHex: "#3CC2B0", isOwner: true, accessibilityName: "Maya Ortiz"), .init(id: "mobile:1", initials: "DV", colorHex: "#EBA946", isOwner: false, accessibilityName: "Dev"), ], - gridLabel: "118×38", - viewerMismatch: true, sizeMode: mode, - countsFromThisDevice: counts, canDisconnectOthers: canDisconnect, - accessibilityLabel: "118 × 38, Maya Ortiz sets the size" + accessibilityLabel: "Size set by Maya's Mac · 118×38" ) } + func testAccessoryShowsOnlyWithParticipants() { + XCTAssertTrue(samplePresence().showsAccessory) + XCTAssertFalse(samplePresence(alone: true).showsAccessory) + } + func testTabItemCodableRoundTripsPresence() throws { let presence = samplePresence() let item = TabItem(title: "zsh", presence: presence) @@ -50,7 +52,7 @@ final class TabPresenceTests: XCTestCase { for mode in TabPresence.SizeMode.allCases { XCTAssertEqual(TabContextAction.sizeMode(mode).sizeMode, mode) } - XCTAssertNil(TabContextAction.showSizePanel.sizeMode) + XCTAssertNil(TabContextAction.toggleSizePanel.sizeMode) } @MainActor @@ -78,25 +80,53 @@ final class TabPresenceTests: XCTestCase { XCTAssertFalse(menu(presence: nil).items.contains { $0.title == "Size to My Window" }) - let withPresence = menu(presence: samplePresence(mode: .priority, counts: false, canDisconnect: false)) - let titles = withPresence.items.map(\.title) - for title in ["Size to My Window", "Don't Resize from This Mac", "Terminal Size", - "Follow Latest Input", "Fit Everyone (Smallest)", "Largest Window", - "Priority List…", "Fixed Size…", "Show Size Panel…", "Disconnect Other Clients…"] { - XCTAssertTrue(titles.contains(title), "missing \(title)") + let alone = menu(presence: samplePresence(mode: .priority, canDisconnect: false, alone: true)) + let titles = alone.items.map(\.title) + XCTAssertTrue(titles.contains("Size to My Window")) + XCTAssertFalse(titles.contains("Disconnect Others…")) + for removed in ["Don't Resize from This Mac", "Show Size Panel…"] { + XCTAssertFalse(titles.contains(removed), "unexpected \(removed)") } - let dontResize = try XCTUnwrap(withPresence.items.first { $0.title == "Don't Resize from This Mac" }) - XCTAssertEqual(dontResize.state, .on) - XCTAssertEqual(withPresence.items.first { $0.title == "Priority List…" }?.state, .on) - XCTAssertEqual(withPresence.items.first { $0.title == "Follow Latest Input" }?.state, .off) - XCTAssertEqual(withPresence.items.first { $0.title == "Disconnect Other Clients…" }?.isEnabled, false) - XCTAssertEqual(withPresence.items.first { $0.title == "Terminal Size" }?.isEnabled, false) - - let largest = try XCTUnwrap(withPresence.items.first { $0.title == "Largest Window" }) + let sizeMenu = try XCTUnwrap(alone.items.first { $0.title == "Terminal Size" }?.submenu) + XCTAssertEqual( + sizeMenu.items.map(\.title), + ["Follow Latest", "Fit Everyone", "Largest Window", "Priority List…", "Fixed Size…"] + ) + XCTAssertEqual(sizeMenu.items.first { $0.title == "Priority List…" }?.state, .on) + XCTAssertEqual(sizeMenu.items.first { $0.title == "Follow Latest" }?.state, .off) + + let shared = menu(presence: samplePresence(mode: .latest, canDisconnect: true)) + XCTAssertTrue(shared.items.contains { $0.title == "Disconnect Others…" }) + + let largest = try XCTUnwrap(sizeMenu.items.first { $0.title == "Largest Window" }) target.performContextAction(largest) XCTAssertEqual(selected, .sizeModeLargest) } + @MainActor + func testPopoverAnchorPrefersVisibleAccessoryOverTabItem() { + let controller = BonsplitController() + let tabId = TabID() + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 300, height: 100), styleMask: [], backing: .buffered, defer: true) + let content = NSView(frame: window.contentLayoutRect) + window.contentView = content + + let item = TabPopoverAnchorView.AnchorNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 30)) + content.addSubview(item) + item.configure(tabId: tabId.id, kind: .tabItem) + XCTAssertTrue(controller.popoverAnchorView(for: tabId) === item) + + let accessory = TabPopoverAnchorView.AnchorNSView(frame: NSRect(x: 80, y: 5, width: 30, height: 18)) + content.addSubview(accessory) + accessory.configure(tabId: tabId.id, kind: .presenceAccessory) + XCTAssertTrue(controller.popoverAnchorView(for: tabId) === accessory) + + accessory.removeFromSuperview() + XCTAssertTrue(controller.popoverAnchorView(for: tabId) === item) + item.isHidden = true + XCTAssertNil(controller.popoverAnchorView(for: tabId)) + } + func testHexColorParsing() { XCTAssertNotNil(Color(tabPresenceHex: "#3CC2B0")) XCTAssertNotNil(Color(tabPresenceHex: "D6C24A")) From b5d7139e1180084c928112020d24edf57a6a3f39 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:35:25 -0700 Subject: [PATCH 3/9] Draw the tab presence accessory in neutral greys with device glyphs Avatars use a neutral grey fill with secondary-label initials; the owner gets a thin labelColor ring at 50% opacity. Participants drop colorHex and gain an optional SF Symbol drawn instead of initials, for the viewer's own devices. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Views/TabPresenceAccessoryView.swift | 48 ++++++++----------- .../Bonsplit/Public/Types/TabPresence.swift | 23 ++++----- Tests/BonsplitTests/TabPresenceTests.swift | 15 +++--- 3 files changed, 41 insertions(+), 45 deletions(-) diff --git a/Sources/Bonsplit/Internal/Views/TabPresenceAccessoryView.swift b/Sources/Bonsplit/Internal/Views/TabPresenceAccessoryView.swift index 1d737cf3..e7b83a01 100644 --- a/Sources/Bonsplit/Internal/Views/TabPresenceAccessoryView.swift +++ b/Sources/Bonsplit/Internal/Views/TabPresenceAccessoryView.swift @@ -1,7 +1,9 @@ import SwiftUI /// The shared-terminal presence accessory drawn after a tab title: up to -/// three overlapping avatars, a ring in the owner's color, and `+N` for the rest. +/// three overlapping neutral grey avatars (initials for other people, a device +/// glyph for the viewer's own other devices), a thin neutral ring on the owner, +/// and `+N` for the rest. No per-participant colors. struct TabPresenceAccessoryView: View { let presence: TabPresence /// Fill behind the tab, used for the avatar separation border. @@ -13,7 +15,7 @@ struct TabPresenceAccessoryView: View { static let maxAvatars = 3 private static let avatarSize: CGFloat = 14 private static let overlap: CGFloat = 4 - private static let ringWidth: CGFloat = 1.5 + private static let ringWidth: CGFloat = 1 var body: some View { let shown = Array(presence.participants.prefix(Self.maxAvatars)) @@ -27,7 +29,7 @@ struct TabPresenceAccessoryView: View { if extra > 0 { Text(verbatim: "+\(extra)") .font(.system(size: 9, weight: .semibold).monospacedDigit()) - .foregroundStyle(textColor.opacity(0.78)) + .foregroundStyle(textColor.opacity(0.7)) .padding(.leading, Self.overlap + 3) .fixedSize() } @@ -42,15 +44,21 @@ struct TabPresenceAccessoryView: View { } private func avatar(_ participant: TabPresence.Participant) -> some View { - let color = Color(tabPresenceHex: participant.colorHex) ?? .gray - return ZStack { - Circle() - .fill(color) - Text(verbatim: participant.initials) - .font(.system(size: 7, weight: .semibold)) - .foregroundStyle(Color(red: 0.055, green: 0.07, blue: 0.094)) - .lineLimit(1) - .minimumScaleFactor(0.6) + ZStack { + // Opaque base so overlapping avatars do not show through each other. + Circle().fill(borderColor) + Circle().fill(Color(nsColor: .quaternaryLabelColor)) + if let symbolName = participant.symbolName { + Image(systemName: symbolName) + .font(.system(size: 7, weight: .medium)) + .foregroundStyle(Color(nsColor: .secondaryLabelColor)) + } else { + Text(verbatim: participant.initials) + .font(.system(size: 7, weight: .semibold)) + .foregroundStyle(Color(nsColor: .secondaryLabelColor)) + .lineLimit(1) + .minimumScaleFactor(0.6) + } } .frame(width: Self.avatarSize, height: Self.avatarSize) .overlay(Circle().stroke(borderColor, lineWidth: 1)) @@ -58,27 +66,13 @@ struct TabPresenceAccessoryView: View { if participant.isOwner { Circle() .inset(by: -(Self.ringWidth + 0.5)) - .stroke(color, lineWidth: Self.ringWidth) + .stroke(Color(nsColor: .labelColor).opacity(0.5), lineWidth: Self.ringWidth) } } .padding(participant.isOwner ? Self.ringWidth + 0.5 : 0) } } -extension Color { - /// Parses `#RRGGBB` (or `RRGGBB`); nil for anything else. - init?(tabPresenceHex hex: String) { - var text = hex.trimmingCharacters(in: .whitespacesAndNewlines) - if text.hasPrefix("#") { text.removeFirst() } - guard text.count == 6, let value = UInt32(text, radix: 16) else { return nil } - self.init( - red: Double((value >> 16) & 0xFF) / 255, - green: Double((value >> 8) & 0xFF) / 255, - blue: Double(value & 0xFF) / 255 - ) - } -} - /// Where the title row reserved space for the presence accessory. struct TabPresenceAccessoryBoundsKey: PreferenceKey { static let defaultValue: Anchor? = nil diff --git a/Sources/Bonsplit/Public/Types/TabPresence.swift b/Sources/Bonsplit/Public/Types/TabPresence.swift index 3325b89c..0f943302 100644 --- a/Sources/Bonsplit/Public/Types/TabPresence.swift +++ b/Sources/Bonsplit/Public/Types/TabPresence.swift @@ -6,14 +6,15 @@ import Foundation /// participants as an avatar accessory after the tab title and adds the /// terminal-size section to the tab context menu. A `nil` presence hides both. public struct TabPresence: Hashable, Sendable, Codable { - /// One attached person or device shown in the tab's avatar stack. + /// One other person, or one of the viewer's own other devices, shown in + /// the tab's avatar stack. Avatars are neutral grey; the owner gets a ring. public struct Participant: Hashable, Sendable, Codable, Identifiable { - /// Host-scoped participant id. + /// Host-scoped id of the person or device group. public var id: String - /// One or two letters drawn inside the avatar. + /// One or two letters drawn inside the avatar when `symbolName` is nil. public var initials: String - /// Avatar fill color as `#RRGGBB`. - public var colorHex: String + /// SF Symbol drawn instead of initials, e.g. `iphone` for the viewer's own phone. + public var symbolName: String? /// Whether this participant sets the terminal size (drawn with a ring). public var isOwner: Bool /// Name read by VoiceOver and shown in help text. @@ -22,15 +23,15 @@ public struct TabPresence: Hashable, Sendable, Codable { /// Creates a participant row. /// /// - Parameters: - /// - id: Host-scoped participant id. + /// - id: Host-scoped id. /// - initials: Letters drawn inside the avatar. - /// - colorHex: Avatar color as `#RRGGBB`. + /// - symbolName: SF Symbol drawn instead of the initials, if any. /// - isOwner: Whether this participant sets the terminal size. /// - accessibilityName: Spoken name. - public init(id: String, initials: String, colorHex: String, isOwner: Bool, accessibilityName: String) { + public init(id: String, initials: String, symbolName: String? = nil, isOwner: Bool, accessibilityName: String) { self.id = id self.initials = initials - self.colorHex = colorHex + self.symbolName = symbolName self.isOwner = isOwner self.accessibilityName = accessibilityName } @@ -50,7 +51,7 @@ public struct TabPresence: Hashable, Sendable, Codable { case fixed } - /// Attached participants, owner first. Empty hides the tab accessory + /// Other attached people and devices (never the viewer), owner first. Empty hides the tab accessory /// while keeping the terminal-size context menu section. public var participants: [Participant] /// The current sizing mode, checked in the context menu. @@ -66,7 +67,7 @@ public struct TabPresence: Hashable, Sendable, Codable { /// Creates a presence snapshot. /// /// - Parameters: - /// - participants: Attached participants, owner first; empty hides the accessory. + /// - participants: Other people and devices, owner first; empty hides the accessory. /// - sizeMode: Current sizing mode. /// - canDisconnectOthers: Whether other clients can be disconnected. /// - accessibilityLabel: Accessory label and tooltip. diff --git a/Tests/BonsplitTests/TabPresenceTests.swift b/Tests/BonsplitTests/TabPresenceTests.swift index 8ec25ffe..57ec3d35 100644 --- a/Tests/BonsplitTests/TabPresenceTests.swift +++ b/Tests/BonsplitTests/TabPresenceTests.swift @@ -7,8 +7,8 @@ final class TabPresenceTests: XCTestCase { private func samplePresence(mode: TabPresence.SizeMode = .priority, canDisconnect: Bool = false, alone: Bool = false) -> TabPresence { TabPresence( participants: alone ? [] : [ - .init(id: "c3", initials: "MO", colorHex: "#3CC2B0", isOwner: true, accessibilityName: "Maya Ortiz"), - .init(id: "mobile:1", initials: "DV", colorHex: "#EBA946", isOwner: false, accessibilityName: "Dev"), + .init(id: "user:u_maya", initials: "MO", isOwner: true, accessibilityName: "Maya Ortiz"), + .init(id: "device:iphone", initials: "", symbolName: "iphone", isOwner: false, accessibilityName: "iPhone"), ], sizeMode: mode, canDisconnectOthers: canDisconnect, @@ -127,10 +127,11 @@ final class TabPresenceTests: XCTestCase { XCTAssertNil(controller.popoverAnchorView(for: tabId)) } - func testHexColorParsing() { - XCTAssertNotNil(Color(tabPresenceHex: "#3CC2B0")) - XCTAssertNotNil(Color(tabPresenceHex: "D6C24A")) - XCTAssertNil(Color(tabPresenceHex: "#12345")) - XCTAssertNil(Color(tabPresenceHex: "zzzzzz")) + func testParticipantDecodesPayloadWithoutSymbolName() throws { + let json = ##"{"id":"c3","initials":"MO","colorHex":"#3CC2B0","isOwner":true,"accessibilityName":"Maya"}"## + let decoded = try JSONDecoder().decode(TabPresence.Participant.self, from: Data(json.utf8)) + XCTAssertEqual(decoded.initials, "MO") + XCTAssertNil(decoded.symbolName) + XCTAssertTrue(decoded.isOwner) } } From acd14379a2b55e804e20f0f246da0776e538ad98 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:37:45 -0700 Subject: [PATCH 4/9] Draw the tab presence accessory in the separator grey Expose BonsplitConfiguration.Appearance.separatorColor, the color split dividers and tab-bar separators already draw (chromeColors.borderHex, else derived from the tab-bar background), so hosts can match it. The presence accessory's owner ring draws in it and avatar fills derive from it. Co-Authored-By: Claude Opus 5.5 (1M context) --- Sources/Bonsplit/Internal/Views/TabItemView.swift | 1 + .../Internal/Views/TabPresenceAccessoryView.swift | 15 ++++++++++----- .../BonsplitConfiguration+SeparatorColor.swift | 12 ++++++++++++ Tests/BonsplitTests/TabPresenceTests.swift | 11 +++++++++++ 4 files changed, 34 insertions(+), 5 deletions(-) create mode 100644 Sources/Bonsplit/Public/BonsplitConfiguration+SeparatorColor.swift diff --git a/Sources/Bonsplit/Internal/Views/TabItemView.swift b/Sources/Bonsplit/Internal/Views/TabItemView.swift index 32bb5f6a..53c857e2 100644 --- a/Sources/Bonsplit/Internal/Views/TabItemView.swift +++ b/Sources/Bonsplit/Internal/Views/TabItemView.swift @@ -417,6 +417,7 @@ struct TabItemView: View { textColor: isSelected ? TabBarColors.activeText(for: appearance) : TabBarColors.inactiveText(for: appearance), + separatorColor: TabBarColors.separator(for: appearance), isHovered: isPresenceHovered, hoverBackground: TabBarColors.hoveredTabBackground(for: appearance) ) diff --git a/Sources/Bonsplit/Internal/Views/TabPresenceAccessoryView.swift b/Sources/Bonsplit/Internal/Views/TabPresenceAccessoryView.swift index e7b83a01..ea93639e 100644 --- a/Sources/Bonsplit/Internal/Views/TabPresenceAccessoryView.swift +++ b/Sources/Bonsplit/Internal/Views/TabPresenceAccessoryView.swift @@ -1,14 +1,18 @@ import SwiftUI /// The shared-terminal presence accessory drawn after a tab title: up to -/// three overlapping neutral grey avatars (initials for other people, a device -/// glyph for the viewer's own other devices), a thin neutral ring on the owner, -/// and `+N` for the rest. No per-participant colors. +/// three overlapping avatars (initials for other people, a device glyph for +/// the viewer's own other devices), a thin ring on the owner, and `+N` for the +/// rest. Avatars and the ring use the appearance's separator grey (the split +/// divider and tab-bar separator color). No per-participant colors. struct TabPresenceAccessoryView: View { let presence: TabPresence /// Fill behind the tab, used for the avatar separation border. let borderColor: Color let textColor: Color + /// The split divider / tab-bar separator grey; the owner ring draws in it + /// and avatar fills derive from it. + let separatorColor: Color let isHovered: Bool let hoverBackground: Color @@ -16,6 +20,7 @@ struct TabPresenceAccessoryView: View { private static let avatarSize: CGFloat = 14 private static let overlap: CGFloat = 4 private static let ringWidth: CGFloat = 1 + private static let avatarFillOpacity: Double = 0.6 var body: some View { let shown = Array(presence.participants.prefix(Self.maxAvatars)) @@ -47,7 +52,7 @@ struct TabPresenceAccessoryView: View { ZStack { // Opaque base so overlapping avatars do not show through each other. Circle().fill(borderColor) - Circle().fill(Color(nsColor: .quaternaryLabelColor)) + Circle().fill(separatorColor.opacity(Self.avatarFillOpacity)) if let symbolName = participant.symbolName { Image(systemName: symbolName) .font(.system(size: 7, weight: .medium)) @@ -66,7 +71,7 @@ struct TabPresenceAccessoryView: View { if participant.isOwner { Circle() .inset(by: -(Self.ringWidth + 0.5)) - .stroke(Color(nsColor: .labelColor).opacity(0.5), lineWidth: Self.ringWidth) + .stroke(separatorColor, lineWidth: Self.ringWidth) } } .padding(participant.isOwner ? Self.ringWidth + 0.5 : 0) diff --git a/Sources/Bonsplit/Public/BonsplitConfiguration+SeparatorColor.swift b/Sources/Bonsplit/Public/BonsplitConfiguration+SeparatorColor.swift new file mode 100644 index 00000000..d3d498b3 --- /dev/null +++ b/Sources/Bonsplit/Public/BonsplitConfiguration+SeparatorColor.swift @@ -0,0 +1,12 @@ +import AppKit + +extension BonsplitConfiguration.Appearance { + /// The one border grey this appearance draws: split dividers, tab-bar + /// separators and pane borders. `chromeColors.borderHex` when set, else a + /// shade derived from the tab-bar background. Hosts draw related chrome + /// (for example shared-terminal sizing bounds) in this color, deriving + /// fills from it with opacity, so every border in the app matches. + public var separatorColor: NSColor { + TabBarColors.nsColorSeparator(for: self) + } +} diff --git a/Tests/BonsplitTests/TabPresenceTests.swift b/Tests/BonsplitTests/TabPresenceTests.swift index 57ec3d35..440a05d7 100644 --- a/Tests/BonsplitTests/TabPresenceTests.swift +++ b/Tests/BonsplitTests/TabPresenceTests.swift @@ -134,4 +134,15 @@ final class TabPresenceTests: XCTestCase { XCTAssertNil(decoded.symbolName) XCTAssertTrue(decoded.isOwner) } + + func testSeparatorColorIsTheDividerAndTabBarSeparatorColor() { + let appearance = BonsplitConfiguration.Appearance( + chromeColors: .init(backgroundHex: "#1E1E1E", borderHex: "#FF000080") + ) + let color = appearance.separatorColor.usingColorSpace(.sRGB)! + XCTAssertEqual(color.redComponent, 1, accuracy: 0.01) + XCTAssertEqual(color.greenComponent, 0, accuracy: 0.01) + XCTAssertEqual(color.alphaComponent, 128.0 / 255.0, accuracy: 0.01) + XCTAssertEqual(appearance.separatorColor, TabBarColors.nsColorSeparator(for: appearance)) + } } From b32f48b92005dba778c6db52aed543eb8311e159 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:27:30 -0700 Subject: [PATCH 5/9] Add a divider-only chrome color ChromeColors.borderHex colors the split divider, the tab-bar underline and tab separators. Hosts that honor a terminal's split-divider color had to overwrite all of them. dividerHex colors only the divider between split panes and falls back to borderHex. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Internal/Styling/TabBarColors.swift | 9 +++++++ .../Internal/Views/SplitContainerView.swift | 4 +-- .../Public/BonsplitConfiguration.swift | 8 +++++- Tests/BonsplitTests/BonsplitTests.swift | 26 +++++++++++++++++++ 4 files changed, 44 insertions(+), 3 deletions(-) diff --git a/Sources/Bonsplit/Internal/Styling/TabBarColors.swift b/Sources/Bonsplit/Internal/Styling/TabBarColors.swift index e5621a3b..84da8d5a 100644 --- a/Sources/Bonsplit/Internal/Styling/TabBarColors.swift +++ b/Sources/Bonsplit/Internal/Styling/TabBarColors.swift @@ -263,6 +263,15 @@ enum TabBarColors { return tone.withAlphaComponent(alpha) } + /// 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 } diff --git a/Sources/Bonsplit/Internal/Views/SplitContainerView.swift b/Sources/Bonsplit/Internal/Views/SplitContainerView.swift index 44cac619..dc463cc5 100644 --- a/Sources/Bonsplit/Internal/Views/SplitContainerView.swift +++ b/Sources/Bonsplit/Internal/Views/SplitContainerView.swift @@ -248,7 +248,7 @@ struct SplitContainerView: 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 @@ -513,7 +513,7 @@ struct SplitContainerView: 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 diff --git a/Sources/Bonsplit/Public/BonsplitConfiguration.swift b/Sources/Bonsplit/Public/BonsplitConfiguration.swift index f375a09f..5b0f1fad 100644 --- a/Sources/Bonsplit/Public/BonsplitConfiguration.swift +++ b/Sources/Bonsplit/Public/BonsplitConfiguration.swift @@ -489,18 +489,24 @@ extension BonsplitConfiguration { /// When unset, Bonsplit derives separators from the chrome background. public var borderHex: String? + /// Optional hex color (`#RRGGBB` or `#RRGGBBAA`) for the divider between split panes only. + /// When unset, Bonsplit falls back to `borderHex`, then derives it from the chrome background. + public var dividerHex: String? + public init( backgroundHex: String? = nil, tabBarBackgroundHex: String? = nil, splitButtonBackdropHex: String? = nil, paneBackgroundHex: String? = nil, - borderHex: String? = nil + borderHex: String? = nil, + dividerHex: String? = nil ) { self.backgroundHex = backgroundHex self.tabBarBackgroundHex = tabBarBackgroundHex self.splitButtonBackdropHex = splitButtonBackdropHex self.paneBackgroundHex = paneBackgroundHex self.borderHex = borderHex + self.dividerHex = dividerHex } } diff --git a/Tests/BonsplitTests/BonsplitTests.swift b/Tests/BonsplitTests/BonsplitTests.swift index 608c72e3..47c60a49 100644 --- a/Tests/BonsplitTests/BonsplitTests.swift +++ b/Tests/BonsplitTests/BonsplitTests.swift @@ -1293,6 +1293,32 @@ final class BonsplitTests: XCTestCase { XCTAssertEqual(Int(round(alpha * 255)), 255) } + func testChromeDividerHexColorsOnlyTheSplitDivider() { + let appearance = BonsplitConfiguration.Appearance( + chromeColors: .init(backgroundHex: "#272822", borderHex: "#112233", dividerHex: "#4A4C47") + ) + let divider = TabBarColors.nsColorSplitDivider(for: appearance).usingColorSpace(.sRGB)! + let separator = TabBarColors.nsColorSeparator(for: appearance).usingColorSpace(.sRGB)! + + XCTAssertEqual(Int(round(divider.redComponent * 255)), 0x4A) + XCTAssertEqual(Int(round(divider.greenComponent * 255)), 0x4C) + XCTAssertEqual(Int(round(divider.blueComponent * 255)), 0x47) + XCTAssertEqual(Int(round(separator.redComponent * 255)), 0x11) + XCTAssertEqual(Int(round(separator.greenComponent * 255)), 0x22) + XCTAssertEqual(Int(round(separator.blueComponent * 255)), 0x33) + } + + func testSplitDividerFallsBackToSeparatorWithoutDividerHex() { + let appearance = BonsplitConfiguration.Appearance( + chromeColors: .init(backgroundHex: "#272822", borderHex: "#112233") + ) + + XCTAssertEqual( + TabBarColors.nsColorSplitDivider(for: appearance), + TabBarColors.nsColorSeparator(for: appearance) + ) + } + func testInvalidChromeBackgroundHexFallsBackToPaneDefaultColor() { let appearance = BonsplitConfiguration.Appearance( chromeColors: .init(backgroundHex: "#ZZZZZZ") From b94451cfaed457f7c1b7ef1bd7932c33691bd122 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:56:21 -0700 Subject: [PATCH 6/9] Test presence colors against a theme matrix Adds BonsplitContrastPalette with the current separator-grey behavior and tests that require 4.5:1 glyphs and text, 3:1 rings and visible fills on light, dark, Solarized, Dracula, low-contrast and extreme themes, and that the tab accessory follows a light tab bar under a dark system appearance. These fail today. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Internal/Styling/TabBarColors.swift | 24 +++++ .../Public/BonsplitContrastPalette.swift | 87 +++++++++++++++ .../BonsplitContrastPaletteTests.swift | 102 ++++++++++++++++++ 3 files changed, 213 insertions(+) create mode 100644 Sources/Bonsplit/Public/BonsplitContrastPalette.swift create mode 100644 Tests/BonsplitTests/BonsplitContrastPaletteTests.swift diff --git a/Sources/Bonsplit/Internal/Styling/TabBarColors.swift b/Sources/Bonsplit/Internal/Styling/TabBarColors.swift index e5621a3b..3e9ceb60 100644 --- a/Sources/Bonsplit/Internal/Styling/TabBarColors.swift +++ b/Sources/Bonsplit/Internal/Styling/TabBarColors.swift @@ -263,6 +263,30 @@ 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 + } + + static func presenceColors( + for appearance: BonsplitConfiguration.Appearance, + isSelected: Bool + ) -> PresenceColors { + let separator = nsColorSeparator(for: appearance) + return PresenceColors( + surface: nsColorBarBackground(for: appearance), + fill: separator.withAlphaComponent(separator.alphaComponent * 0.6), + glyph: .secondaryLabelColor, + text: effectiveTextColor(for: appearance, secondary: true), + line: separator + ) + } + static var dropIndicator: Color { Color.accentColor } diff --git a/Sources/Bonsplit/Public/BonsplitContrastPalette.swift b/Sources/Bonsplit/Public/BonsplitContrastPalette.swift new file mode 100644 index 00000000..e2560bdd --- /dev/null +++ b/Sources/Bonsplit/Public/BonsplitContrastPalette.swift @@ -0,0 +1,87 @@ +import AppKit + +/// Neutral colors for chrome drawn on a themed surface (the shared-terminal +/// presence accessory and sizing bounds). +public struct BonsplitContrastPalette: Equatable, Sendable { + /// An opaque gamma-encoded sRGB color, components in 0...1. + public struct RGB: Equatable, Hashable, Sendable { + public var red: Double + public var green: Double + public var blue: Double + + public init(red: Double, green: Double, blue: Double) { + self.red = red + self.green = green + self.blue = blue + } + + /// `#rrggbb` or `rrggbb`. + public init?(hex: String) { + var value = hex.trimmingCharacters(in: .whitespacesAndNewlines) + if value.hasPrefix("#") { value.removeFirst() } + guard value.count == 6, let rgb = UInt32(value, radix: 16) else { return nil } + self.init( + red: Double((rgb >> 16) & 0xFF) / 255, + green: Double((rgb >> 8) & 0xFF) / 255, + blue: Double(rgb & 0xFF) / 255 + ) + } + + public func mixed(toward other: RGB, by amount: Double) -> RGB { + let t = min(max(amount, 0), 1) + return RGB( + red: red + (other.red - red) * t, + green: green + (other.green - green) * t, + blue: blue + (other.blue - blue) * t + ) + } + + public var relativeLuminance: Double { + func linear(_ c: Double) -> Double { + c <= 0.04045 ? c / 12.92 : pow((c + 0.055) / 1.055, 2.4) + } + return 0.2126 * linear(red) + 0.7152 * linear(green) + 0.0722 * linear(blue) + } + } + + public let background: RGB + public let foreground: RGB + public let fill: RGB + public let glyph: RGB + public let text: RGB + public let line: RGB + public let hatch: RGB + + public static func contrastRatio(_ a: RGB, _ b: RGB) -> Double { + let la = a.relativeLuminance, lb = b.relativeLuminance + return (max(la, lb) + 0.05) / (min(la, lb) + 0.05) + } + + // Stub: the separator-grey behavior this palette replaces. + public init(background: RGB, foreground: RGB) { + self.background = background + self.foreground = foreground + let light = background.relativeLuminance > 0.18 + let tone = light + ? RGB(red: background.red - 0.12, green: background.green - 0.12, blue: background.blue - 0.12) + : RGB(red: background.red + 0.16, green: background.green + 0.16, blue: background.blue + 0.16) + let alpha = light ? 0.26 : 0.36 + fill = background.mixed(toward: tone, by: alpha * 0.6) + line = background.mixed(toward: tone, by: alpha) + hatch = background.mixed(toward: tone, by: alpha * 0.35) + glyph = RGB(red: 0.6, green: 0.6, blue: 0.6) + text = glyph + } + + /// `color` as opaque sRGB in the current drawing appearance, composited + /// over `base` when translucent. + public static func rgb(_ color: NSColor, over base: NSColor = .windowBackgroundColor) -> RGB { + let resolved = color.usingColorSpace(.sRGB) ?? NSColor.gray + let own = RGB(red: resolved.redComponent, green: resolved.greenComponent, blue: resolved.blueComponent) + let alpha = Double(resolved.alphaComponent) + guard alpha < 0.999 else { return own } + let under = base.usingColorSpace(.sRGB) ?? NSColor.windowBackgroundColor + let baseRGB = RGB(red: under.redComponent, green: under.greenComponent, blue: under.blueComponent) + return baseRGB.mixed(toward: own, by: alpha) + } +} diff --git a/Tests/BonsplitTests/BonsplitContrastPaletteTests.swift b/Tests/BonsplitTests/BonsplitContrastPaletteTests.swift new file mode 100644 index 00000000..e73b6184 --- /dev/null +++ b/Tests/BonsplitTests/BonsplitContrastPaletteTests.swift @@ -0,0 +1,102 @@ +import XCTest +@testable import Bonsplit +import AppKit + +/// Theme matrix for the neutral presence / sizing palette. Every theme must +/// meet each contrast floor: glyphs and text 4.5:1, rings and borders 3:1, +/// fills visibly off the background. +final class BonsplitContrastPaletteTests: XCTestCase { + typealias RGB = BonsplitContrastPalette.RGB + + static let themes: [(name: String, background: String, foreground: String)] = [ + ("default light", "#ffffff", "#1d1d1f"), + ("macOS window light", "#ececec", "#262626"), + ("default dark", "#282c34", "#ffffff"), + ("solarized light", "#fdf6e3", "#657b83"), + ("solarized dark", "#002b36", "#839496"), + ("low-contrast dark", "#1e1e1e", "#3a3a3a"), + ("pure white", "#ffffff", "#000000"), + ("pure black", "#000000", "#ffffff"), + ("mid grey / white", "#808080", "#ffffff"), + ("mid grey / black", "#808080", "#000000"), + ("dracula", "#282a36", "#f8f8f2"), + ] + + private func palette(_ theme: (name: String, background: String, foreground: String)) throws -> BonsplitContrastPalette { + BonsplitContrastPalette( + background: try XCTUnwrap(RGB(hex: theme.background), theme.name), + foreground: try XCTUnwrap(RGB(hex: theme.foreground), theme.name) + ) + } + + func testEveryThemeMeetsEveryContrastFloor() throws { + for theme in Self.themes { + let p = try palette(theme) + let ratio = BonsplitContrastPalette.contrastRatio + XCTAssertGreaterThanOrEqual(ratio(p.glyph, p.fill), 4.5, "\(theme.name): glyph on fill") + XCTAssertGreaterThanOrEqual(ratio(p.text, p.background), 4.5, "\(theme.name): text on background") + XCTAssertGreaterThanOrEqual(ratio(p.line, p.background), 3.0, "\(theme.name): line on background") + XCTAssertGreaterThanOrEqual(ratio(p.fill, p.background), 1.2, "\(theme.name): fill visible") + XCTAssertGreaterThanOrEqual(ratio(p.hatch, p.background), 1.3, "\(theme.name): hatch visible") + // Subtle: the fill never competes with the glyph it carries. + XCTAssertLessThan(ratio(p.fill, p.background), 2.2, "\(theme.name): fill subtle") + } + } + + func testPaletteStaysNeutralForNeutralThemes() throws { + let p = try palette(("pure white", "#ffffff", "#000000")) + for color in [p.fill, p.glyph, p.text, p.line, p.hatch] { + XCTAssertEqual(color.red, color.green, accuracy: 0.001) + XCTAssertEqual(color.green, color.blue, accuracy: 0.001) + } + } + + func testContrastRatioMatchesWCAGReferencePoints() throws { + let white = try XCTUnwrap(RGB(hex: "#ffffff")) + let black = try XCTUnwrap(RGB(hex: "#000000")) + XCTAssertEqual(BonsplitContrastPalette.contrastRatio(white, black), 21, accuracy: 0.01) + XCTAssertEqual(BonsplitContrastPalette.contrastRatio(white, try XCTUnwrap(RGB(hex: "#767676"))), 4.54, accuracy: 0.01) + } + + /// The reported bug: a light terminal theme while macOS runs dark. The + /// accessory must follow the tab bar's own colors, not the system label. + func testPresenceColorsFollowLightTabBarUnderDarkSystemAppearance() throws { + var appearance = BonsplitConfiguration.Appearance() + appearance.chromeColors = .init(backgroundHex: "#fdf6e3") + let dark = try XCTUnwrap(NSAppearance(named: .darkAqua)) + for isSelected in [true, false] { + let colors = TabBarColors.presenceColors(for: appearance, isSelected: isSelected) + var surface = RGB(red: 0, green: 0, blue: 0) + var fill = surface, glyph = surface, line = surface, text = surface + dark.performAsCurrentDrawingAppearance { + surface = BonsplitContrastPalette.rgb(colors.surface) + fill = BonsplitContrastPalette.rgb(colors.fill) + glyph = BonsplitContrastPalette.rgb(colors.glyph) + line = BonsplitContrastPalette.rgb(colors.line) + text = BonsplitContrastPalette.rgb(colors.text) + } + XCTAssertGreaterThan(surface.relativeLuminance, 0.5, "selected=\(isSelected): tab surface is light") + XCTAssertGreaterThanOrEqual(BonsplitContrastPalette.contrastRatio(glyph, fill), 4.5, "selected=\(isSelected)") + XCTAssertGreaterThanOrEqual(BonsplitContrastPalette.contrastRatio(text, surface), 4.5, "selected=\(isSelected)") + XCTAssertGreaterThanOrEqual(BonsplitContrastPalette.contrastRatio(line, surface), 3.0, "selected=\(isSelected)") + } + } + + /// Without theme colors the accessory uses the system surface and label, + /// resolved in whichever appearance draws it. + func testPresenceColorsResolveSystemColorsPerAppearance() throws { + let colors = TabBarColors.presenceColors(for: BonsplitConfiguration.Appearance(), isSelected: false) + for name in [NSAppearance.Name.aqua, .darkAqua] { + let drawing = try XCTUnwrap(NSAppearance(named: name)) + var surface = RGB(red: 0, green: 0, blue: 0) + var fill = surface, glyph = surface + drawing.performAsCurrentDrawingAppearance { + surface = BonsplitContrastPalette.rgb(colors.surface) + fill = BonsplitContrastPalette.rgb(colors.fill) + glyph = BonsplitContrastPalette.rgb(colors.glyph) + } + XCTAssertEqual(surface.relativeLuminance > 0.5, name == .aqua, "\(name.rawValue)") + XCTAssertGreaterThanOrEqual(BonsplitContrastPalette.contrastRatio(glyph, fill), 4.5, "\(name.rawValue)") + } + } +} From 3793c0a4fd153887753508131f66da52e0f39f7e Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:59:19 -0700 Subject: [PATCH 7/9] Derive presence colors from the tab surface with contrast floors The accessory drew its glyphs in the system secondary label color and its fills as the separator grey at 60% alpha, so a light terminal theme under a dark macOS appearance showed light glyphs on a light tab. BonsplitContrastPalette now mixes the tab bar's text color into the tab surface in sRGB and nudges each role toward black or white until glyphs and text reach 4.5:1, rings 3:1 and fills stay visible. Colors are dynamic, so system fallbacks resolve in the drawing appearance. Hosts use the same palette for pane chrome. Removes the unused Appearance.separatorColor. Adds an opt-in PNG theme proof. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Internal/Styling/TabBarColors.swift | 35 +++- .../Bonsplit/Internal/Views/TabItemView.swift | 8 +- .../Views/TabPresenceAccessoryView.swift | 28 ++-- ...BonsplitConfiguration+SeparatorColor.swift | 12 -- .../Public/BonsplitContrastPalette.swift | 134 ++++++++++++++-- Tests/BonsplitTests/TabPresenceTests.swift | 11 -- .../TabPresenceThemeProofTests.swift | 149 ++++++++++++++++++ 7 files changed, 311 insertions(+), 66 deletions(-) delete mode 100644 Sources/Bonsplit/Public/BonsplitConfiguration+SeparatorColor.swift create mode 100644 Tests/BonsplitTests/TabPresenceThemeProofTests.swift diff --git a/Sources/Bonsplit/Internal/Styling/TabBarColors.swift b/Sources/Bonsplit/Internal/Styling/TabBarColors.swift index 3e9ceb60..2513dddc 100644 --- a/Sources/Bonsplit/Internal/Styling/TabBarColors.swift +++ b/Sources/Bonsplit/Internal/Styling/TabBarColors.swift @@ -273,20 +273,43 @@ enum TabBarColors { 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 separator = nsColorSeparator(for: appearance) + 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: nsColorBarBackground(for: appearance), - fill: separator.withAlphaComponent(separator.alphaComponent * 0.6), - glyph: .secondaryLabelColor, - text: effectiveTextColor(for: appearance, secondary: true), - line: separator + 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) + } + static var dropIndicator: Color { Color.accentColor } diff --git a/Sources/Bonsplit/Internal/Views/TabItemView.swift b/Sources/Bonsplit/Internal/Views/TabItemView.swift index 53c857e2..415d1d3a 100644 --- a/Sources/Bonsplit/Internal/Views/TabItemView.swift +++ b/Sources/Bonsplit/Internal/Views/TabItemView.swift @@ -411,13 +411,7 @@ struct TabItemView: View { private func presenceAccessory(_ presence: TabPresence) -> some View { TabPresenceAccessoryView( presence: presence, - borderColor: isSelected - ? TabBarColors.activeTabBackground(for: appearance) - : TabBarColors.barBackground(for: appearance), - textColor: isSelected - ? TabBarColors.activeText(for: appearance) - : TabBarColors.inactiveText(for: appearance), - separatorColor: TabBarColors.separator(for: appearance), + colors: TabBarColors.presenceColors(for: appearance, isSelected: isSelected), isHovered: isPresenceHovered, hoverBackground: TabBarColors.hoveredTabBackground(for: appearance) ) diff --git a/Sources/Bonsplit/Internal/Views/TabPresenceAccessoryView.swift b/Sources/Bonsplit/Internal/Views/TabPresenceAccessoryView.swift index ea93639e..f2a92d85 100644 --- a/Sources/Bonsplit/Internal/Views/TabPresenceAccessoryView.swift +++ b/Sources/Bonsplit/Internal/Views/TabPresenceAccessoryView.swift @@ -3,16 +3,12 @@ import SwiftUI /// The shared-terminal presence accessory drawn after a tab title: up to /// three overlapping avatars (initials for other people, a device glyph for /// the viewer's own other devices), a thin ring on the owner, and `+N` for the -/// rest. Avatars and the ring use the appearance's separator grey (the split -/// divider and tab-bar separator color). No per-participant colors. +/// rest. Neutral colors derived from the tab surface and the tab bar's text +/// color (``TabBarColors/presenceColors(for:isSelected:)``), with 4.5:1 +/// glyphs and 3:1 rings on any theme. No per-participant colors. struct TabPresenceAccessoryView: View { let presence: TabPresence - /// Fill behind the tab, used for the avatar separation border. - let borderColor: Color - let textColor: Color - /// The split divider / tab-bar separator grey; the owner ring draws in it - /// and avatar fills derive from it. - let separatorColor: Color + let colors: TabBarColors.PresenceColors let isHovered: Bool let hoverBackground: Color @@ -20,7 +16,6 @@ struct TabPresenceAccessoryView: View { private static let avatarSize: CGFloat = 14 private static let overlap: CGFloat = 4 private static let ringWidth: CGFloat = 1 - private static let avatarFillOpacity: Double = 0.6 var body: some View { let shown = Array(presence.participants.prefix(Self.maxAvatars)) @@ -34,7 +29,7 @@ struct TabPresenceAccessoryView: View { if extra > 0 { Text(verbatim: "+\(extra)") .font(.system(size: 9, weight: .semibold).monospacedDigit()) - .foregroundStyle(textColor.opacity(0.7)) + .foregroundStyle(Color(nsColor: colors.text)) .padding(.leading, Self.overlap + 3) .fixedSize() } @@ -50,28 +45,27 @@ struct TabPresenceAccessoryView: View { private func avatar(_ participant: TabPresence.Participant) -> some View { ZStack { - // Opaque base so overlapping avatars do not show through each other. - Circle().fill(borderColor) - Circle().fill(separatorColor.opacity(Self.avatarFillOpacity)) + // Opaque, so overlapping avatars do not show through each other. + Circle().fill(Color(nsColor: colors.fill)) if let symbolName = participant.symbolName { Image(systemName: symbolName) .font(.system(size: 7, weight: .medium)) - .foregroundStyle(Color(nsColor: .secondaryLabelColor)) + .foregroundStyle(Color(nsColor: colors.glyph)) } else { Text(verbatim: participant.initials) .font(.system(size: 7, weight: .semibold)) - .foregroundStyle(Color(nsColor: .secondaryLabelColor)) + .foregroundStyle(Color(nsColor: colors.glyph)) .lineLimit(1) .minimumScaleFactor(0.6) } } .frame(width: Self.avatarSize, height: Self.avatarSize) - .overlay(Circle().stroke(borderColor, lineWidth: 1)) + .overlay(Circle().stroke(Color(nsColor: colors.surface), lineWidth: 1)) .overlay { if participant.isOwner { Circle() .inset(by: -(Self.ringWidth + 0.5)) - .stroke(separatorColor, lineWidth: Self.ringWidth) + .stroke(Color(nsColor: colors.line), lineWidth: Self.ringWidth) } } .padding(participant.isOwner ? Self.ringWidth + 0.5 : 0) diff --git a/Sources/Bonsplit/Public/BonsplitConfiguration+SeparatorColor.swift b/Sources/Bonsplit/Public/BonsplitConfiguration+SeparatorColor.swift deleted file mode 100644 index d3d498b3..00000000 --- a/Sources/Bonsplit/Public/BonsplitConfiguration+SeparatorColor.swift +++ /dev/null @@ -1,12 +0,0 @@ -import AppKit - -extension BonsplitConfiguration.Appearance { - /// The one border grey this appearance draws: split dividers, tab-bar - /// separators and pane borders. `chromeColors.borderHex` when set, else a - /// shade derived from the tab-bar background. Hosts draw related chrome - /// (for example shared-terminal sizing bounds) in this color, deriving - /// fills from it with opacity, so every border in the app matches. - public var separatorColor: NSColor { - TabBarColors.nsColorSeparator(for: self) - } -} diff --git a/Sources/Bonsplit/Public/BonsplitContrastPalette.swift b/Sources/Bonsplit/Public/BonsplitContrastPalette.swift index e2560bdd..6eb4cf8f 100644 --- a/Sources/Bonsplit/Public/BonsplitContrastPalette.swift +++ b/Sources/Bonsplit/Public/BonsplitContrastPalette.swift @@ -1,7 +1,9 @@ import AppKit -/// Neutral colors for chrome drawn on a themed surface (the shared-terminal -/// presence accessory and sizing bounds). +/// Neutral colors for chrome drawn on a themed surface: the shared-terminal +/// presence accessory on a tab, and hosts' sizing bounds on a pane. Every +/// role derives from the actual surface (``background``) and its text color +/// (``foreground``), with contrast floors that hold on any theme. public struct BonsplitContrastPalette: Equatable, Sendable { /// An opaque gamma-encoded sRGB color, components in 0...1. public struct RGB: Equatable, Hashable, Sendable { @@ -44,33 +46,139 @@ public struct BonsplitContrastPalette: Equatable, Sendable { } } + /// Minimum contrast of glyphs, initials and chip text against the fill. + public static let glyphContrastFloor = 4.5 + /// Minimum contrast of text drawn directly on the background. + public static let textContrastFloor = 4.5 + /// Minimum contrast of rings, borders and outlines against the background. + public static let lineContrastFloor = 3.0 + /// Minimum contrast that keeps a fill visible against the background. + public static let fillContrastFloor = 1.2 + /// Minimum contrast that keeps hatch lines visible against the background. + public static let hatchContrastFloor = 1.3 + + /// Share of the foreground mixed into the background for each role. + static let fillMix = 0.14 + static let hatchMix = 0.22 + static let lineMix = 0.4 + static let glyphMix = 0.72 + static let textMix = 0.72 + /// Headroom above each floor so 8-bit rendering never drops below it. + static let floorMargin = 0.03 + + /// The surface the chrome sits on. public let background: RGB + /// The text color of that surface; every role mixes toward it. public let foreground: RGB + /// Avatar and chip fill: subtle, always visibly off the background. public let fill: RGB + /// Glyphs, initials and chip text drawn on ``fill``. public let glyph: RGB + /// Text drawn on ``background`` (for example `+2`). public let text: RGB + /// Owner ring, grid border and chip outline. public let line: RGB + /// Hatch lines outside the grid. public let hatch: RGB + /// WCAG 2 contrast ratio, 1...21. public static func contrastRatio(_ a: RGB, _ b: RGB) -> Double { let la = a.relativeLuminance, lb = b.relativeLuminance return (max(la, lb) + 0.05) / (min(la, lb) + 0.05) } - // Stub: the separator-grey behavior this palette replaces. + /// Derives every role by mixing `foreground` into `background` in + /// gamma-encoded sRGB at a fixed ratio, then moving the result toward + /// black or white until its contrast floor holds (WCAG 2 relative + /// luminance). Neutral themes give neutral greys; the only hue is the + /// theme's own background and foreground. public init(background: RGB, foreground: RGB) { self.background = background self.foreground = foreground - let light = background.relativeLuminance > 0.18 - let tone = light - ? RGB(red: background.red - 0.12, green: background.green - 0.12, blue: background.blue - 0.12) - : RGB(red: background.red + 0.16, green: background.green + 0.16, blue: background.blue + 0.16) - let alpha = light ? 0.26 : 0.36 - fill = background.mixed(toward: tone, by: alpha * 0.6) - line = background.mixed(toward: tone, by: alpha) - hatch = background.mixed(toward: tone, by: alpha * 0.35) - glyph = RGB(red: 0.6, green: 0.6, blue: 0.6) - text = glyph + let fill = Self.ensuring( + background.mixed(toward: foreground, by: Self.fillMix), + floor: Self.fillContrastFloor, against: background, toward: foreground + ) + self.fill = fill + hatch = Self.ensuring( + background.mixed(toward: foreground, by: Self.hatchMix), + floor: Self.hatchContrastFloor, against: background, toward: foreground + ) + line = Self.ensuring( + background.mixed(toward: foreground, by: Self.lineMix), + floor: Self.lineContrastFloor, against: background, toward: foreground + ) + glyph = Self.ensuring( + fill.mixed(toward: foreground, by: Self.glyphMix), + floor: Self.glyphContrastFloor, against: fill, toward: foreground + ) + text = Self.ensuring( + background.mixed(toward: foreground, by: Self.textMix), + floor: Self.textContrastFloor, against: background, toward: foreground + ) + } + + /// `color`, or the smallest move from it toward black or white that + /// reaches `floor` against `reference`. It prefers the pole on + /// `direction`'s side of the reference, and takes the other pole when + /// that one cannot reach the floor. A floor of 4.58 or less is always + /// reachable against an opaque color. + static func ensuring(_ color: RGB, floor: Double, against reference: RGB, toward direction: RGB) -> RGB { + let target = floor + floorMargin + if contrastRatio(color, reference) >= target { return color } + let white = RGB(red: 1, green: 1, blue: 1) + let black = RGB(red: 0, green: 0, blue: 0) + let preferred = direction.relativeLuminance >= reference.relativeLuminance ? white : black + let other = preferred == white ? black : white + let pole = contrastRatio(preferred, reference) >= target ? preferred : other + var step = 1 + while step <= 100 { + let candidate = color.mixed(toward: pole, by: Double(step) / 100) + if contrastRatio(candidate, reference) >= target { return candidate } + step += 1 + } + return pole + } + + public enum Role: Sendable, CaseIterable { + case fill, glyph, text, line, hatch + } + + public func rgb(_ role: Role) -> RGB { + switch role { + case .fill: fill + case .glyph: glyph + case .text: text + case .line: line + case .hatch: hatch + } + } + + /// The palette for `background` and `foreground` resolved in the current + /// drawing appearance. Translucent colors composite over `base`, and the + /// foreground over the composited background. + public init(background: NSColor, foreground: NSColor, base: NSColor = .windowBackgroundColor) { + let surface = Self.rgb(background, over: base) + let surfaceColor = NSColor(srgbRed: surface.red, green: surface.green, blue: surface.blue, alpha: 1) + self.init(background: surface, foreground: Self.rgb(foreground, over: surfaceColor)) + } + + /// A dynamic color for `role` that re-derives the palette from + /// `background` and `foreground` in whichever appearance draws it, so + /// system colors (window background, label) resolve correctly. + public static func dynamicColor( + _ role: Role, + background: NSColor, + foreground: NSColor, + base: NSColor = .windowBackgroundColor + ) -> NSColor { + NSColor(name: nil) { appearance in + var rgb = RGB(red: 0.5, green: 0.5, blue: 0.5) + appearance.performAsCurrentDrawingAppearance { + rgb = BonsplitContrastPalette(background: background, foreground: foreground, base: base).rgb(role) + } + return NSColor(srgbRed: rgb.red, green: rgb.green, blue: rgb.blue, alpha: 1) + } } /// `color` as opaque sRGB in the current drawing appearance, composited diff --git a/Tests/BonsplitTests/TabPresenceTests.swift b/Tests/BonsplitTests/TabPresenceTests.swift index 440a05d7..57ec3d35 100644 --- a/Tests/BonsplitTests/TabPresenceTests.swift +++ b/Tests/BonsplitTests/TabPresenceTests.swift @@ -134,15 +134,4 @@ final class TabPresenceTests: XCTestCase { XCTAssertNil(decoded.symbolName) XCTAssertTrue(decoded.isOwner) } - - func testSeparatorColorIsTheDividerAndTabBarSeparatorColor() { - let appearance = BonsplitConfiguration.Appearance( - chromeColors: .init(backgroundHex: "#1E1E1E", borderHex: "#FF000080") - ) - let color = appearance.separatorColor.usingColorSpace(.sRGB)! - XCTAssertEqual(color.redComponent, 1, accuracy: 0.01) - XCTAssertEqual(color.greenComponent, 0, accuracy: 0.01) - XCTAssertEqual(color.alphaComponent, 128.0 / 255.0, accuracy: 0.01) - XCTAssertEqual(appearance.separatorColor, TabBarColors.nsColorSeparator(for: appearance)) - } } diff --git a/Tests/BonsplitTests/TabPresenceThemeProofTests.swift b/Tests/BonsplitTests/TabPresenceThemeProofTests.swift new file mode 100644 index 00000000..2c4d6ddb --- /dev/null +++ b/Tests/BonsplitTests/TabPresenceThemeProofTests.swift @@ -0,0 +1,149 @@ +import XCTest +@testable import Bonsplit +import AppKit +import SwiftUI + +/// Renders the presence accessory on a tab, and a replica of the host's pane +/// chip / grid border, for several themes. Opt-in: set +/// `BONSPLIT_THEME_PROOF_DIR` to write one PNG per theme for visual review. +@MainActor +final class TabPresenceThemeProofTests: XCTestCase { + private static let themes: [(name: String, background: String, foreground: String)] = [ + ("light", "#ffffff", "#1d1d1f"), + ("dark", "#282c34", "#ffffff"), + ("solarized-light", "#fdf6e3", "#657b83"), + ("solarized-dark", "#002b36", "#839496"), + ("dracula", "#282a36", "#f8f8f2"), + ("low-contrast-dark", "#1e1e1e", "#3a3a3a"), + ("mid-grey", "#808080", "#ffffff"), + ] + + func testRenderThemeProofs() throws { + guard let directory = ProcessInfo.processInfo.environment["BONSPLIT_THEME_PROOF_DIR"] else { + throw XCTSkip("Set BONSPLIT_THEME_PROOF_DIR to render theme proofs.") + } + try FileManager.default.createDirectory(atPath: directory, withIntermediateDirectories: true) + for theme in Self.themes { + for systemAppearance in [NSAppearance.Name.aqua, .darkAqua] { + let view = ThemeProofView(background: theme.background, foreground: theme.foreground) + .environment(\.colorScheme, systemAppearance == .aqua ? .light : .dark) + let host = NSHostingView(rootView: view) + host.appearance = NSAppearance(named: systemAppearance) + host.frame = NSRect(origin: .zero, size: host.fittingSize) + host.layoutSubtreeIfNeeded() + let rep = try XCTUnwrap(host.bitmapImageRepForCachingDisplay(in: host.bounds)) + host.cacheDisplay(in: host.bounds, to: rep) + let data = try XCTUnwrap(rep.representation(using: .png, properties: [:])) + let suffix = systemAppearance == .aqua ? "system-light" : "system-dark" + try data.write(to: URL(fileURLWithPath: directory).appendingPathComponent("mac-\(theme.name)-\(suffix).png")) + } + } + } +} + +private struct ThemeProofView: View { + let background: String + let foreground: String + + private var appearance: BonsplitConfiguration.Appearance { + BonsplitConfiguration.Appearance(chromeColors: .init(backgroundHex: background)) + } + + private var presence: TabPresence { + TabPresence( + participants: [ + .init(id: "user:u_maya", initials: "MO", isOwner: true, accessibilityName: "Maya"), + .init(id: "device:iphone", initials: "", symbolName: "iphone", isOwner: false, accessibilityName: "iPhone"), + .init(id: "user:u_li", initials: "LC", isOwner: false, accessibilityName: "Li"), + .init(id: "user:u_a", initials: "A", isOwner: false, accessibilityName: "A"), + ], + sizeMode: .smallest, + canDisconnectOthers: false, + accessibilityLabel: "Size" + ) + } + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 0) { + tab(title: "Claude Code", selected: true) + tab(title: "zsh", selected: false) + Spacer(minLength: 0) + } + .frame(height: 30) + .background(TabBarColors.barBackground(for: appearance)) + pane + } + .frame(width: 420) + } + + private func tab(title: String, selected: Bool) -> some View { + HStack(spacing: 6) { + Text(title) + .font(.system(size: 11)) + .foregroundStyle(selected + ? TabBarColors.activeText(for: appearance) + : TabBarColors.inactiveText(for: appearance)) + TabPresenceAccessoryView( + presence: presence, + colors: TabBarColors.presenceColors(for: appearance, isSelected: selected), + isHovered: false, + hoverBackground: .clear + ) + } + .padding(.horizontal, 10) + .frame(maxHeight: .infinity) + .background(selected ? TabBarColors.activeTabBackground(for: appearance) : .clear) + } + + /// Mirrors the host's `TerminalSizeBoundsOverlayView`: border on the + /// open sides, hatch outside the grid, and the chip below it. + private var pane: some View { + let palette = BonsplitContrastPalette( + background: BonsplitContrastPalette.RGB(hex: background)!, + foreground: BonsplitContrastPalette.RGB(hex: foreground)! + ) + func color(_ rgb: BonsplitContrastPalette.RGB) -> Color { + Color(nsColor: NSColor(srgbRed: rgb.red, green: rgb.green, blue: rgb.blue, alpha: 1)) + } + let grid = CGRect(x: 0, y: 0, width: 280, height: 90) + return Canvas { context, size in + context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(color(palette.background))) + var hatch = Path() + var x = -size.height + while x < size.width { + hatch.move(to: CGPoint(x: x, y: size.height)) + hatch.addLine(to: CGPoint(x: x + size.height, y: 0)) + x += 8 + } + var outside = Path(CGRect(origin: .zero, size: size)) + outside.addRect(grid) + context.drawLayer { layer in + layer.clip(to: outside, style: FillStyle(eoFill: true)) + layer.stroke(hatch, with: .color(color(palette.hatch)), lineWidth: 0.5) + } + for (index, line) in ["$ claude", "> Refactor the sizing palette", " Reading 4 files…"].enumerated() { + context.draw( + Text(line).font(.system(size: 11, design: .monospaced)).foregroundColor(color(palette.foreground)), + at: CGPoint(x: 8, y: 12 + CGFloat(index) * 16), + anchor: .leading + ) + } + var border = Path() + border.move(to: CGPoint(x: grid.maxX - 0.5, y: 0)) + border.addLine(to: CGPoint(x: grid.maxX - 0.5, y: grid.maxY - 0.5)) + border.addLine(to: CGPoint(x: 0, y: grid.maxY - 0.5)) + context.stroke(border, with: .color(color(palette.line)), lineWidth: 1) + let chipText = context.resolve( + Text("118×38 · Maya's Mac").font(.system(size: 11).monospacedDigit()).foregroundColor(color(palette.glyph)) + ) + let textSize = chipText.measure(in: size) + let chip = CGRect(x: grid.maxX - textSize.width - 12, y: grid.maxY + 4, width: textSize.width + 12, height: textSize.height + 6) + let chipPath = Path(roundedRect: chip.insetBy(dx: 0.5, dy: 0.5), cornerRadius: 4) + context.fill(chipPath, with: .color(color(palette.fill))) + context.stroke(chipPath, with: .color(color(palette.line)), lineWidth: 1) + context.draw(chipText, at: CGPoint(x: chip.midX, y: chip.midY)) + } + .frame(height: 130) + } +} From a6d9aa04820499671d8341522e038a14dc603a2a Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:39:56 -0700 Subject: [PATCH 8/9] Test that presence avatars do not overlap Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Views/TabPresenceAccessoryView.swift | 5 ++-- Tests/BonsplitTests/TabPresenceTests.swift | 26 +++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/Sources/Bonsplit/Internal/Views/TabPresenceAccessoryView.swift b/Sources/Bonsplit/Internal/Views/TabPresenceAccessoryView.swift index f2a92d85..0c07cd85 100644 --- a/Sources/Bonsplit/Internal/Views/TabPresenceAccessoryView.swift +++ b/Sources/Bonsplit/Internal/Views/TabPresenceAccessoryView.swift @@ -13,7 +13,8 @@ struct TabPresenceAccessoryView: View { let hoverBackground: Color static let maxAvatars = 3 - private static let avatarSize: CGFloat = 14 + static let avatarSize: CGFloat = 14 + static let horizontalPadding: CGFloat = 3 private static let overlap: CGFloat = 4 private static let ringWidth: CGFloat = 1 @@ -34,7 +35,7 @@ struct TabPresenceAccessoryView: View { .fixedSize() } } - .padding(.horizontal, 3) + .padding(.horizontal, Self.horizontalPadding) .padding(.vertical, 1) .background( Capsule(style: .continuous) diff --git a/Tests/BonsplitTests/TabPresenceTests.swift b/Tests/BonsplitTests/TabPresenceTests.swift index 57ec3d35..6e7a03a0 100644 --- a/Tests/BonsplitTests/TabPresenceTests.swift +++ b/Tests/BonsplitTests/TabPresenceTests.swift @@ -16,6 +16,32 @@ final class TabPresenceTests: XCTestCase { ) } + /// Overlapping avatars clipped the first letter of every later one + /// ("MC" read ".C"). Three avatars must sit side by side, so the accessory + /// is at least three avatars plus its padding wide. + @MainActor + func testAccessoryAvatarsDoNotOverlap() { + let presence = TabPresence( + participants: [ + .init(id: "user:u_li", initials: "LC", isOwner: false, accessibilityName: "Li"), + .init(id: "user:u_mc", initials: "MC", isOwner: false, accessibilityName: "Mo"), + .init(id: "user:u_ww", initials: "WW", isOwner: false, accessibilityName: "Wu"), + ], + sizeMode: .smallest, + canDisconnectOthers: false, + accessibilityLabel: "Size" + ) + let appearance = BonsplitConfiguration.Appearance(chromeColors: .init(backgroundHex: "#ffffff")) + let host = NSHostingView(rootView: TabPresenceAccessoryView( + presence: presence, + colors: TabBarColors.presenceColors(for: appearance, isSelected: true), + isHovered: false, + hoverBackground: .clear + )) + let avatars = CGFloat(TabPresenceAccessoryView.maxAvatars) * TabPresenceAccessoryView.avatarSize + XCTAssertGreaterThanOrEqual(host.fittingSize.width, avatars + 2 * TabPresenceAccessoryView.horizontalPadding) + } + func testAccessoryShowsOnlyWithParticipants() { XCTAssertTrue(samplePresence().showsAccessory) XCTAssertFalse(samplePresence(alone: true).showsAccessory) From ef76137cc30e5f6f1dd06bd0d38e4c694b606a33 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:40:19 -0700 Subject: [PATCH 9/9] Place presence avatars side by side A 4 pt overlap clipped the first letter of the second and third avatars. A 2 pt gap keeps every glyph whole; the owner ring is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Internal/Views/TabPresenceAccessoryView.swift | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/Sources/Bonsplit/Internal/Views/TabPresenceAccessoryView.swift b/Sources/Bonsplit/Internal/Views/TabPresenceAccessoryView.swift index 0c07cd85..4b093e50 100644 --- a/Sources/Bonsplit/Internal/Views/TabPresenceAccessoryView.swift +++ b/Sources/Bonsplit/Internal/Views/TabPresenceAccessoryView.swift @@ -1,7 +1,7 @@ import SwiftUI /// The shared-terminal presence accessory drawn after a tab title: up to -/// three overlapping avatars (initials for other people, a device glyph for +/// three avatars side by side (initials for other people, a device glyph for /// the viewer's own other devices), a thin ring on the owner, and `+N` for the /// rest. Neutral colors derived from the tab surface and the tab bar's text /// color (``TabBarColors/presenceColors(for:isSelected:)``), with 4.5:1 @@ -15,23 +15,23 @@ struct TabPresenceAccessoryView: View { static let maxAvatars = 3 static let avatarSize: CGFloat = 14 static let horizontalPadding: CGFloat = 3 - private static let overlap: CGFloat = 4 + /// Gap between avatars. Two 7 pt initials are about 10.5 pt wide in a + /// 14 pt circle, so any overlap clips the next avatar's first letter. + private static let avatarSpacing: CGFloat = 2 private static let ringWidth: CGFloat = 1 var body: some View { let shown = Array(presence.participants.prefix(Self.maxAvatars)) let extra = presence.participants.count - shown.count - HStack(spacing: -Self.overlap) { - // Later avatars sit under earlier ones so the owner (first) stays on top. - ForEach(Array(shown.enumerated()), id: \.element.id) { index, participant in + HStack(spacing: Self.avatarSpacing) { + ForEach(shown) { participant in avatar(participant) - .zIndex(Double(shown.count - index)) } if extra > 0 { Text(verbatim: "+\(extra)") .font(.system(size: 9, weight: .semibold).monospacedDigit()) .foregroundStyle(Color(nsColor: colors.text)) - .padding(.leading, Self.overlap + 3) + .padding(.leading, 1) .fixedSize() } } @@ -46,7 +46,6 @@ struct TabPresenceAccessoryView: View { private func avatar(_ participant: TabPresence.Participant) -> some View { ZStack { - // Opaque, so overlapping avatars do not show through each other. Circle().fill(Color(nsColor: colors.fill)) if let symbolName = participant.symbolName { Image(systemName: symbolName)