From 0459e256f024349487ecf55957973d5066566f4e Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:55:21 -0700 Subject: [PATCH 01/10] Add mutable per-tab backgrounds --- CHANGELOG.md | 3 + Example/BonsplitExample/AppState.swift | 25 +++++ .../BonsplitExample/BonsplitExampleApp.swift | 8 ++ README.md | 8 ++ .../Bonsplit/Internal/Models/TabItem.swift | 6 ++ .../Internal/Styling/TabBarColors.swift | 94 ++++++++++++++++++ .../Internal/Views/TabDragPreview.swift | 19 +++- .../Bonsplit/Internal/Views/TabItemView.swift | 87 +++++++++-------- .../Bonsplit/Public/BonsplitController.swift | 12 +++ Sources/Bonsplit/Public/Types/Tab.swift | 5 + Tests/BonsplitTests/BonsplitTests.swift | 97 +++++++++++++++++++ 11 files changed, 323 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 288aee74..940f6199 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Per-tab `backgroundHex` metadata on `Tab`, `createTab`, and `updateTab`. Hosts can + update a tab's icon and background in one call, clear either value explicitly, and + retain the style through splits, drag transfers, and persisted tab decoding. - `BonsplitConfiguration.Appearance.tabWidthMode` (`TabWidthMode`) to control tab sizing. - `.fixed` (default) keeps the historical fixed-width + horizontal-scroll layout unchanged. - `.fill` stretches tabs to fill the pane's available tab-bar width, distributing the diff --git a/Example/BonsplitExample/AppState.swift b/Example/BonsplitExample/AppState.swift index 8d47f884..d4ffdeac 100644 --- a/Example/BonsplitExample/AppState.swift +++ b/Example/BonsplitExample/AppState.swift @@ -49,6 +49,31 @@ class AppState { _ = controller.closeTab(tab.id) } + func cycleCurrentTabAppearance() { + guard let paneId = controller.focusedPaneId, + let tab = controller.selectedTab(inPane: paneId) else { return } + switch tab.backgroundHex { + case nil: + controller.updateTab( + tab.id, + icon: .some("checkmark.circle.fill"), + backgroundHex: .some("#2E7D32") + ) + case "#2E7D32": + controller.updateTab( + tab.id, + icon: .some("paintbrush.fill"), + backgroundHex: .some("#6A4C93") + ) + default: + controller.updateTab( + tab.id, + icon: .some("doc.text"), + backgroundHex: .some(nil) + ) + } + } + func splitHorizontal() { // Split creates empty pane - we create a tab via the delegate callback _ = controller.splitPane(orientation: .horizontal) diff --git a/Example/BonsplitExample/BonsplitExampleApp.swift b/Example/BonsplitExample/BonsplitExampleApp.swift index e8d58e3f..afe0e676 100644 --- a/Example/BonsplitExample/BonsplitExampleApp.swift +++ b/Example/BonsplitExample/BonsplitExampleApp.swift @@ -25,9 +25,17 @@ struct BonsplitExampleApp: App { struct DebugCommands: Commands { @Environment(\.openWindow) var openWindow + @FocusedObject var appState: AppState? var body: some Commands { CommandMenu("Debug") { + Button("Cycle Selected Tab Appearance") { + appState?.cycleCurrentTabAppearance() + } + .keyboardShortcut("a", modifiers: [.command, .option]) + + Divider() + Button("Show Geometry Debug") { openWindow(id: "debug") } diff --git a/README.md b/README.md index dd78a145..42bdf292 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,7 @@ The main controller for managing tabs and panes. let tabId = controller.createTab( title: "Document.swift", icon: "swift", // SF Symbol name (optional) + backgroundHex: "#315C8C", // Per-tab background (optional) isDirty: false, // Show dirty indicator (optional) inPane: paneId // Target pane (optional, defaults to focused) ) @@ -90,6 +91,12 @@ let tabId = controller.createTab( controller.updateTab(tabId, title: "NewName.swift") controller.updateTab(tabId, isDirty: true) controller.updateTab(tabId, icon: "doc.text") +controller.updateTab( + tabId, + icon: .some("checkmark.circle.fill"), + backgroundHex: .some("#2E7D32") +) +controller.updateTab(tabId, backgroundHex: .some(nil)) // Restore the default background // Close a tab controller.closeTab(tabId) @@ -196,6 +203,7 @@ public struct Tab { public let id: TabID public let title: String public let icon: String? + public let backgroundHex: String? public let isDirty: Bool } ``` diff --git a/Sources/Bonsplit/Internal/Models/TabItem.swift b/Sources/Bonsplit/Internal/Models/TabItem.swift index 8ee7f74d..9e793811 100644 --- a/Sources/Bonsplit/Internal/Models/TabItem.swift +++ b/Sources/Bonsplit/Internal/Models/TabItem.swift @@ -21,6 +21,7 @@ struct TabItem: Identifiable, Hashable, Codable { var icon: String? var iconImageData: Data? var iconAsset: String? + var backgroundHex: String? var kind: String? var isDirty: Bool var showsNotificationBadge: Bool @@ -39,6 +40,7 @@ struct TabItem: Identifiable, Hashable, Codable { icon: String? = "doc.text", iconImageData: Data? = nil, iconAsset: String? = nil, + backgroundHex: String? = nil, kind: String? = nil, isDirty: Bool = false, showsNotificationBadge: Bool = false, @@ -54,6 +56,7 @@ struct TabItem: Identifiable, Hashable, Codable { self.icon = icon self.iconImageData = iconImageData self.iconAsset = iconAsset + self.backgroundHex = backgroundHex self.kind = kind self.isDirty = isDirty self.showsNotificationBadge = showsNotificationBadge @@ -79,6 +82,7 @@ struct TabItem: Identifiable, Hashable, Codable { case icon case iconImageData case iconAsset + case backgroundHex case kind case isDirty case showsNotificationBadge @@ -97,6 +101,7 @@ struct TabItem: Identifiable, Hashable, Codable { self.icon = try c.decodeIfPresent(String.self, forKey: .icon) self.iconImageData = try c.decodeIfPresent(Data.self, forKey: .iconImageData) self.iconAsset = try c.decodeIfPresent(String.self, forKey: .iconAsset) + self.backgroundHex = try c.decodeIfPresent(String.self, forKey: .backgroundHex) self.kind = try c.decodeIfPresent(String.self, forKey: .kind) self.isDirty = try c.decodeIfPresent(Bool.self, forKey: .isDirty) ?? false self.showsNotificationBadge = try c.decodeIfPresent(Bool.self, forKey: .showsNotificationBadge) ?? false @@ -115,6 +120,7 @@ struct TabItem: Identifiable, Hashable, Codable { try c.encodeIfPresent(icon, forKey: .icon) try c.encodeIfPresent(iconImageData, forKey: .iconImageData) try c.encodeIfPresent(iconAsset, forKey: .iconAsset) + try c.encodeIfPresent(backgroundHex, forKey: .backgroundHex) try c.encodeIfPresent(kind, forKey: .kind) try c.encode(isDirty, forKey: .isDirty) try c.encode(showsNotificationBadge, forKey: .showsNotificationBadge) diff --git a/Sources/Bonsplit/Internal/Styling/TabBarColors.swift b/Sources/Bonsplit/Internal/Styling/TabBarColors.swift index e5621a3b..d30d5c8b 100644 --- a/Sources/Bonsplit/Internal/Styling/TabBarColors.swift +++ b/Sources/Bonsplit/Internal/Styling/TabBarColors.swift @@ -106,6 +106,49 @@ enum TabBarColors { return NSColor.white.withAlphaComponent(alpha) } + private static func tabBackgroundColor(from value: String?) -> NSColor? { + guard let value else { return nil } + return nonClearColor(NSColor(bonsplitHex: value)) + } + + private static func compositedTabBackground( + _ foreground: NSColor, + over background: NSColor + ) -> NSColor { + guard let foreground = foreground.usingColorSpace(.sRGB), + let background = background.usingColorSpace(.sRGB) else { + return foreground + } + let alpha = foreground.alphaComponent + let inverseAlpha = 1 - alpha + return NSColor( + red: foreground.redComponent * alpha + background.redComponent * inverseAlpha, + green: foreground.greenComponent * alpha + background.greenComponent * inverseAlpha, + blue: foreground.blueComponent * alpha + background.blueComponent * inverseAlpha, + alpha: 1 + ) + } + + private static func effectiveTabTextColor( + for appearance: BonsplitConfiguration.Appearance, + backgroundHex: String?, + secondary: Bool + ) -> NSColor { + guard let customBackground = tabBackgroundColor(from: backgroundHex) else { + return effectiveTextColor(for: appearance, secondary: secondary) + } + let contrastBackground = compositedTabBackground( + customBackground, + over: nsColorBarBackground(for: appearance) + ) + if contrastBackground.isBonsplitLightColor { + let alpha = secondary ? Constants.darkSecondaryTextAlpha : Constants.darkTextAlpha + return NSColor.black.withAlphaComponent(alpha) + } + let alpha = secondary ? Constants.lightSecondaryTextAlpha : Constants.lightTextAlpha + return NSColor.white.withAlphaComponent(alpha) + } + static func paneBackground(for appearance: BonsplitConfiguration.Appearance) -> Color { Color(nsColor: paneBackgroundColor(for: appearance) ?? .textBackgroundColor) } @@ -201,6 +244,32 @@ enum TabBarColors { .clear } + /// Resolves a tab's background, including its selected and hover states. + /// + /// A valid per-tab color remains visible in every state. Hovering adjusts its + /// lightness slightly; selection continues to use the tab strip's indicator. + static func tabBackground( + for appearance: BonsplitConfiguration.Appearance, + backgroundHex: String?, + isSelected: Bool, + isHovered: Bool + ) -> Color { + if let custom = tabBackgroundColor(from: backgroundHex) { + guard isHovered && !isSelected else { return Color(nsColor: custom) } + let adjusted = custom.isBonsplitLightColor + ? custom.bonsplitDarken(by: 0.03) + : custom.bonsplitLighten(by: 0.07) + return Color(nsColor: adjusted) + } + if isSelected { + return activeTabBackground(for: appearance) + } + if isHovered { + return hoveredTabBackground(for: appearance) + } + return inactiveTabBackground + } + // MARK: - Text Colors static var activeText: Color { @@ -227,6 +296,31 @@ enum TabBarColors { effectiveTextColor(for: appearance, secondary: true) } + /// Resolves readable tab text and symbol color against a per-tab background. + static func tabText( + for appearance: BonsplitConfiguration.Appearance, + backgroundHex: String?, + isSelected: Bool + ) -> Color { + Color(nsColor: nsColorTabText( + for: appearance, + backgroundHex: backgroundHex, + isSelected: isSelected + )) + } + + static func nsColorTabText( + for appearance: BonsplitConfiguration.Appearance, + backgroundHex: String?, + isSelected: Bool + ) -> NSColor { + effectiveTabTextColor( + for: appearance, + backgroundHex: backgroundHex, + secondary: !isSelected + ) + } + static func splitActionIcon(for appearance: BonsplitConfiguration.Appearance, isPressed: Bool) -> Color { Color(nsColor: nsColorSplitActionIcon(for: appearance, isPressed: isPressed)) } diff --git a/Sources/Bonsplit/Internal/Views/TabDragPreview.swift b/Sources/Bonsplit/Internal/Views/TabDragPreview.swift index 075f65cc..aab09a13 100644 --- a/Sources/Bonsplit/Internal/Views/TabDragPreview.swift +++ b/Sources/Bonsplit/Internal/Views/TabDragPreview.swift @@ -10,19 +10,32 @@ struct TabDragPreview: View { if let iconName = tab.icon { Image(systemName: iconName) .font(.system(size: TabBarMetrics.iconSize)) - .foregroundStyle(TabBarColors.activeText(for: appearance)) + .foregroundStyle(TabBarColors.tabText( + for: appearance, + backgroundHex: tab.backgroundHex, + isSelected: true + )) } Text(tab.title) .font(.system(size: appearance.tabTitleFontSize)) .lineLimit(1) - .foregroundStyle(TabBarColors.activeText(for: appearance)) + .foregroundStyle(TabBarColors.tabText( + for: appearance, + backgroundHex: tab.backgroundHex, + isSelected: true + )) } .padding(.horizontal, 12) .padding(.vertical, 6) .background( RoundedRectangle(cornerRadius: TabBarMetrics.tabCornerRadius, style: .continuous) - .fill(TabBarColors.activeTabBackground(for: appearance)) + .fill(TabBarColors.tabBackground( + for: appearance, + backgroundHex: tab.backgroundHex, + isSelected: true, + isHovered: false + )) .shadow(color: .black.opacity(0.2), radius: 4, y: 2) ) .opacity(0.9) diff --git a/Sources/Bonsplit/Internal/Views/TabItemView.swift b/Sources/Bonsplit/Internal/Views/TabItemView.swift index dae03ec2..629125c0 100644 --- a/Sources/Bonsplit/Internal/Views/TabItemView.swift +++ b/Sources/Bonsplit/Internal/Views/TabItemView.swift @@ -398,6 +398,34 @@ struct TabItemView: View { } } + private var emphasizedTabText: Color { + TabBarColors.tabText( + for: appearance, + backgroundHex: tab.backgroundHex, + isSelected: true + ) + } + + private var subduedTabText: Color { + TabBarColors.tabText( + for: appearance, + backgroundHex: tab.backgroundHex, + isSelected: false + ) + } + + private var currentTabText: Color { + isSelected ? emphasizedTabText : subduedTabText + } + + private var currentTabTextNSColor: NSColor { + TabBarColors.nsColorTabText( + for: appearance, + backgroundHex: tab.backgroundHex, + isSelected: isSelected + ) + } + /// Whether this tab renders in the compact icon-only style reserved for pinned /// browser surfaces (favicon only, no title or trailing affordances). private var isIconOnlyPinned: Bool { @@ -538,11 +566,7 @@ struct TabItemView: View { Text(tab.title) .font(.system(size: appearance.tabTitleFontSize)) .lineLimit(1) - .foregroundStyle( - isSelected - ? TabBarColors.activeText(for: appearance) - : TabBarColors.inactiveText(for: appearance) - ) + .foregroundStyle(currentTabText) .saturation(saturation) .tabGeometryDebugFrame { frame in debugRecordGeometry(which: "title", frame: frame) @@ -552,10 +576,7 @@ struct TabItemView: View { Image(systemName: "network") .font(.system(size: accessoryFontSize, weight: .semibold)) .foregroundStyle( - (isSelected - ? TabBarColors.activeText(for: appearance) - : TabBarColors.inactiveText(for: appearance)) - .opacity(0.78) + currentTabText.opacity(0.78) ) .saturation(saturation) .accessibilityHidden(true) @@ -580,13 +601,8 @@ struct TabItemView: View { .font(.system(size: accessoryFontSize, weight: .semibold)) .foregroundStyle( isAudioHovered - ? (isSelected - ? TabBarColors.activeText(for: appearance) - : TabBarColors.inactiveText(for: appearance)) - : (isSelected - ? TabBarColors.activeText(for: appearance) - : TabBarColors.inactiveText(for: appearance)) - .opacity(0.78) + ? currentTabText + : currentTabText.opacity(0.78) ) .frame(width: accessorySlotSize, height: accessorySlotSize) .background( @@ -618,8 +634,8 @@ struct TabItemView: View { .font(.system(size: accessoryFontSize, weight: .semibold)) .foregroundStyle( isZoomHovered - ? TabBarColors.activeText(for: appearance) - : TabBarColors.inactiveText(for: appearance) + ? emphasizedTabText + : subduedTabText ) .frame(width: accessorySlotSize, height: accessorySlotSize) .background( @@ -692,9 +708,7 @@ struct TabItemView: View { @ViewBuilder private var leadingIcon: some View { let iconSlotSize = scaledIconSize - let iconTintColor = isSelected - ? TabBarColors.nsColorActiveText(for: appearance) - : TabBarColors.nsColorInactiveText(for: appearance) + let iconTintColor = currentTabTextNSColor let iconTint = Color(nsColor: iconTintColor) let faviconImage = renderedFaviconImage ?? tab.iconImageData.flatMap { NSImage(data: $0) } @@ -771,11 +785,7 @@ struct TabItemView: View { } label: { Image(systemName: isMuted ? "speaker.slash.fill" : "speaker.wave.2.fill") .font(.system(size: max(6, accessoryFontSize - 4), weight: .semibold)) - .foregroundStyle( - isSelected - ? TabBarColors.activeText(for: appearance) - : TabBarColors.inactiveText(for: appearance) - ) + .foregroundStyle(currentTabText) .padding(2) .background( Circle().fill(TabBarColors.activeTabBackground(for: appearance)) @@ -998,15 +1008,16 @@ struct TabItemView: View { @ViewBuilder private var tabBackground: some View { ZStack(alignment: .top) { - if isSelected { - Rectangle() - .fill(TabBarColors.activeTabBackground(for: appearance)) - } else if TabItemStyling.shouldShowHoverBackground(isHovered: isHovered, isSelected: isSelected) { - Rectangle() - .fill(TabBarColors.hoveredTabBackground(for: appearance)) - } else { - Color.clear - } + Rectangle() + .fill(TabBarColors.tabBackground( + for: appearance, + backgroundHex: tab.backgroundHex, + isSelected: isSelected, + isHovered: TabItemStyling.shouldShowHoverBackground( + isHovered: isHovered, + isSelected: isSelected + ) + )) // Right border separator HStack { @@ -1045,7 +1056,7 @@ struct TabItemView: View { if isSelected || isHovered || isCloseHovered || (!tab.isDirty && !tab.showsNotificationBadge) { Image(systemName: "pin.fill") .font(.system(size: scaledCloseIconSize, weight: .semibold)) - .foregroundStyle(TabBarColors.inactiveText(for: appearance)) + .foregroundStyle(subduedTabText) .frame(width: accessorySlotSize, height: accessorySlotSize) .saturation(saturation) } @@ -1058,8 +1069,8 @@ struct TabItemView: View { .font(.system(size: scaledCloseIconSize, weight: .semibold)) .foregroundStyle( isCloseHovered - ? TabBarColors.activeText(for: appearance) - : TabBarColors.inactiveText(for: appearance) + ? emphasizedTabText + : subduedTabText ) .frame(width: accessorySlotSize, height: accessorySlotSize) .background( diff --git a/Sources/Bonsplit/Public/BonsplitController.swift b/Sources/Bonsplit/Public/BonsplitController.swift index edcd9bc3..15a70ab1 100644 --- a/Sources/Bonsplit/Public/BonsplitController.swift +++ b/Sources/Bonsplit/Public/BonsplitController.swift @@ -162,6 +162,7 @@ public final class BonsplitController { /// - icon: Optional SF Symbol name for the tab icon /// - iconImageData: Optional image data (PNG recommended) for the tab icon. When present, takes precedence over `icon`. /// - iconAsset: Optional asset-catalog image name for the tab icon. Resolved in the host app bundle. + /// - backgroundHex: Optional per-tab background color as `#RRGGBB` or `#RRGGBBAA`. /// - kind: Consumer-defined tab kind identifier (e.g. "terminal", "browser") /// - hasCustomTitle: Whether the tab title came from a custom user override /// - isDirty: Whether the tab shows a dirty indicator @@ -178,6 +179,7 @@ public final class BonsplitController { icon: String? = "doc.text", iconImageData: Data? = nil, iconAsset: String? = nil, + backgroundHex: String? = nil, kind: String? = nil, isDirty: Bool = false, showsNotificationBadge: Bool = false, @@ -196,6 +198,7 @@ public final class BonsplitController { icon: icon, iconImageData: iconImageData, iconAsset: iconAsset, + backgroundHex: backgroundHex, kind: kind, isDirty: isDirty, showsNotificationBadge: showsNotificationBadge, @@ -237,6 +240,7 @@ public final class BonsplitController { icon: icon, iconImageData: iconImageData, iconAsset: iconAsset, + backgroundHex: backgroundHex, kind: kind, isDirty: isDirty, showsNotificationBadge: showsNotificationBadge, @@ -289,6 +293,7 @@ public final class BonsplitController { /// - icon: New icon (pass nil to keep current, pass .some(nil) to remove icon) /// - iconImageData: New icon image data (pass nil to keep current, pass .some(nil) to remove) /// - iconAsset: New asset-catalog icon name (pass nil to keep current, pass .some(nil) to remove) + /// - backgroundHex: New per-tab background color (pass nil to keep current, pass .some(nil) to remove) /// - kind: New tab kind (pass nil to keep current, pass .some(nil) to clear) /// - hasCustomTitle: New custom-title state (pass nil to keep current) /// - isDirty: New dirty state (pass nil to keep current) @@ -303,6 +308,7 @@ public final class BonsplitController { icon: String?? = nil, iconImageData: Data?? = nil, iconAsset: String?? = nil, + backgroundHex: String?? = nil, kind: String?? = nil, hasCustomTitle: Bool? = nil, isDirty: Bool? = nil, @@ -320,6 +326,7 @@ public final class BonsplitController { icon.map { currentTab.icon != $0 } ?? false || iconImageData.map { currentTab.iconImageData != $0 } ?? false || iconAsset.map { currentTab.iconAsset != $0 } ?? false || + backgroundHex.map { currentTab.backgroundHex != $0 } ?? false || kind.map { currentTab.kind != $0 } ?? false || hasCustomTitle.map { currentTab.hasCustomTitle != $0 } ?? false || isDirty.map { currentTab.isDirty != $0 } ?? false || @@ -343,6 +350,9 @@ public final class BonsplitController { if let iconAsset = iconAsset { pane.tabs[tabIndex].iconAsset = iconAsset } + if let backgroundHex = backgroundHex { + pane.tabs[tabIndex].backgroundHex = backgroundHex + } if let kind = kind { pane.tabs[tabIndex].kind = kind } @@ -533,6 +543,7 @@ public final class BonsplitController { icon: tab.icon, iconImageData: tab.iconImageData, iconAsset: tab.iconAsset, + backgroundHex: tab.backgroundHex, kind: tab.kind, isDirty: tab.isDirty, showsNotificationBadge: tab.showsNotificationBadge, @@ -602,6 +613,7 @@ public final class BonsplitController { icon: tab.icon, iconImageData: tab.iconImageData, iconAsset: tab.iconAsset, + backgroundHex: tab.backgroundHex, kind: tab.kind, isDirty: tab.isDirty, showsNotificationBadge: tab.showsNotificationBadge, diff --git a/Sources/Bonsplit/Public/Types/Tab.swift b/Sources/Bonsplit/Public/Types/Tab.swift index 8f11c6e2..e50752fc 100644 --- a/Sources/Bonsplit/Public/Types/Tab.swift +++ b/Sources/Bonsplit/Public/Types/Tab.swift @@ -10,6 +10,8 @@ public struct Tab: Identifiable, Hashable, Sendable { public let iconImageData: Data? /// Optional asset-catalog image name for the tab icon. Resolved in the host app bundle. public let iconAsset: String? + /// Optional per-tab background color as `#RRGGBB` or `#RRGGBBAA`. + public let backgroundHex: String? /// Consumer-defined tab kind identifier (for example, "terminal" or "browser"). public let kind: String? public let isDirty: Bool @@ -34,6 +36,7 @@ public struct Tab: Identifiable, Hashable, Sendable { icon: String? = nil, iconImageData: Data? = nil, iconAsset: String? = nil, + backgroundHex: String? = nil, kind: String? = nil, isDirty: Bool = false, showsNotificationBadge: Bool = false, @@ -49,6 +52,7 @@ public struct Tab: Identifiable, Hashable, Sendable { self.icon = icon self.iconImageData = iconImageData self.iconAsset = iconAsset + self.backgroundHex = backgroundHex self.kind = kind self.isDirty = isDirty self.showsNotificationBadge = showsNotificationBadge @@ -66,6 +70,7 @@ public struct Tab: Identifiable, Hashable, Sendable { self.icon = tabItem.icon self.iconImageData = tabItem.iconImageData self.iconAsset = tabItem.iconAsset + self.backgroundHex = tabItem.backgroundHex self.kind = tabItem.kind self.isDirty = tabItem.isDirty self.showsNotificationBadge = tabItem.showsNotificationBadge diff --git a/Tests/BonsplitTests/BonsplitTests.swift b/Tests/BonsplitTests/BonsplitTests.swift index 55364085..a2367d5f 100644 --- a/Tests/BonsplitTests/BonsplitTests.swift +++ b/Tests/BonsplitTests/BonsplitTests.swift @@ -228,6 +228,44 @@ final class BonsplitTests: XCTestCase { XCTAssertNil(controller.tab(tabId)?.iconAsset) } + @MainActor + func testTabIconAndBackgroundCreateUpdateClearRoundTrips() { + let controller = BonsplitController() + let tabId = controller.createTab( + title: "Styled", + icon: "doc", + backgroundHex: "#112233" + )! + + XCTAssertEqual(controller.tab(tabId)?.icon, "doc") + XCTAssertEqual(controller.tab(tabId)?.backgroundHex, "#112233") + + controller.updateTab( + tabId, + icon: .some("terminal.fill"), + backgroundHex: .some("#AABBCC80") + ) + XCTAssertEqual(controller.tab(tabId)?.icon, "terminal.fill") + XCTAssertEqual(controller.tab(tabId)?.backgroundHex, "#AABBCC80") + + controller.updateTab( + tabId, + icon: .some(nil), + backgroundHex: .some(nil) + ) + XCTAssertNil(controller.tab(tabId)?.icon) + XCTAssertNil(controller.tab(tabId)?.backgroundHex) + } + + func testLegacyTabItemDecodesWithoutBackground() throws { + let data = Data(#"{"id":"11111111-1111-1111-1111-111111111111","title":"Legacy"}"#.utf8) + + let tab = try JSONDecoder().decode(TabItem.self, from: data) + + XCTAssertEqual(tab.title, "Legacy") + XCTAssertNil(tab.backgroundHex) + } + @MainActor func testNoopTabUpdateDoesNotInvalidateObservedTabMetadata() { let controller = BonsplitController() @@ -236,6 +274,7 @@ final class BonsplitTests: XCTestCase { hasCustomTitle: true, icon: "doc", iconAsset: "AgentIcons/Claude", + backgroundHex: "#112233", kind: "terminal", isDirty: true, showsNotificationBadge: true, @@ -257,6 +296,7 @@ final class BonsplitTests: XCTestCase { title: "Original", icon: .some("doc"), iconAsset: .some("AgentIcons/Claude"), + backgroundHex: .some("#112233"), kind: .some("terminal"), hasCustomTitle: true, isDirty: true, @@ -305,6 +345,23 @@ final class BonsplitTests: XCTestCase { XCTAssertEqual(controller.tab(playing.id)?.isAudioPlaying, true) } + @MainActor + func testSplitPaneWithTabPreservesIconAndBackground() { + let controller = BonsplitController() + _ = controller.createTab(title: "Base") + let styled = Bonsplit.Tab( + title: "Styled", + icon: "paintbrush.fill", + backgroundHex: "#336699" + ) + + let newPane = controller.splitPane(orientation: .horizontal, withTab: styled) + + XCTAssertNotNil(newPane) + XCTAssertEqual(controller.tab(styled.id)?.icon, "paintbrush.fill") + XCTAssertEqual(controller.tab(styled.id)?.backgroundHex, "#336699") + } + @MainActor func testTabClose() { let controller = BonsplitController() @@ -1138,6 +1195,46 @@ final class BonsplitTests: XCTestCase { XCTAssertEqual(Int(round(alpha * 255)), 255) } + func testPerTabBackgroundResolvesWithReadableForeground() { + let appearance = BonsplitConfiguration.Appearance() + let background = NSColor(TabBarColors.tabBackground( + for: appearance, + backgroundHex: "#112233", + isSelected: true, + isHovered: false + )).usingColorSpace(.sRGB)! + let foreground = TabBarColors.nsColorTabText( + for: appearance, + backgroundHex: "#112233", + isSelected: true + ).usingColorSpace(.sRGB)! + + XCTAssertEqual(Int(round(background.redComponent * 255)), 17) + XCTAssertEqual(Int(round(background.greenComponent * 255)), 34) + XCTAssertEqual(Int(round(background.blueComponent * 255)), 51) + XCTAssertGreaterThan(foreground.redComponent, 0.9) + XCTAssertGreaterThan(foreground.greenComponent, 0.9) + XCTAssertGreaterThan(foreground.blueComponent, 0.9) + } + + func testInvalidPerTabBackgroundFallsBackToStandardSelectedStyle() { + let appearance = BonsplitConfiguration.Appearance() + let invalid = NSColor(TabBarColors.tabBackground( + for: appearance, + backgroundHex: "#nope", + isSelected: true, + isHovered: false + )).usingColorSpace(.sRGB)! + let standard = NSColor( + TabBarColors.activeTabBackground(for: appearance) + ).usingColorSpace(.sRGB)! + + XCTAssertEqual(invalid.redComponent, standard.redComponent, accuracy: 0.0001) + XCTAssertEqual(invalid.greenComponent, standard.greenComponent, accuracy: 0.0001) + XCTAssertEqual(invalid.blueComponent, standard.blueComponent, accuracy: 0.0001) + XCTAssertEqual(invalid.alphaComponent, standard.alphaComponent, accuracy: 0.0001) + } + func testPaneBackgroundHexOverrideCanDifferFromChromeBackground() { let appearance = BonsplitConfiguration.Appearance( chromeColors: .init( From 8787d06905aa4fd66e22f280052ba6dd11aabb94 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:24:27 -0700 Subject: [PATCH 02/10] Keep custom tab controls readable --- .../Internal/Styling/TabBarColors.swift | 125 +++++++++++++++--- .../Bonsplit/Internal/Views/TabItemView.swift | 62 ++++++--- Tests/BonsplitTests/BonsplitTests.swift | 53 ++++++++ 3 files changed, 204 insertions(+), 36 deletions(-) diff --git a/Sources/Bonsplit/Internal/Styling/TabBarColors.swift b/Sources/Bonsplit/Internal/Styling/TabBarColors.swift index d30d5c8b..819b000d 100644 --- a/Sources/Bonsplit/Internal/Styling/TabBarColors.swift +++ b/Sources/Bonsplit/Internal/Styling/TabBarColors.swift @@ -3,6 +3,11 @@ import AppKit /// Native macOS colors for the tab bar enum TabBarColors { + struct TabControlColorPair { + let foreground: Color + let background: Color + } + private enum Constants { static let darkTextAlpha: CGFloat = 0.82 static let darkSecondaryTextAlpha: CGFloat = 0.62 @@ -111,35 +116,78 @@ enum TabBarColors { return nonClearColor(NSColor(bonsplitHex: value)) } - private static func compositedTabBackground( + private static func compositedColor( _ foreground: NSColor, over background: NSColor ) -> NSColor { guard let foreground = foreground.usingColorSpace(.sRGB), let background = background.usingColorSpace(.sRGB) else { - return foreground + return foreground.withAlphaComponent(1) } - let alpha = foreground.alphaComponent - let inverseAlpha = 1 - alpha + let foregroundAlpha = foreground.alphaComponent + let backgroundAlpha = background.alphaComponent + let outputAlpha = foregroundAlpha + backgroundAlpha * (1 - foregroundAlpha) + guard outputAlpha > 0.001 else { return .clear } + let backgroundContribution = backgroundAlpha * (1 - foregroundAlpha) return NSColor( - red: foreground.redComponent * alpha + background.redComponent * inverseAlpha, - green: foreground.greenComponent * alpha + background.greenComponent * inverseAlpha, - blue: foreground.blueComponent * alpha + background.blueComponent * inverseAlpha, - alpha: 1 + red: ( + foreground.redComponent * foregroundAlpha + + background.redComponent * backgroundContribution + ) / outputAlpha, + green: ( + foreground.greenComponent * foregroundAlpha + + background.greenComponent * backgroundContribution + ) / outputAlpha, + blue: ( + foreground.blueComponent * foregroundAlpha + + background.blueComponent * backgroundContribution + ) / outputAlpha, + alpha: outputAlpha + ) + } + + private static func effectiveTabBarSurface( + for appearance: BonsplitConfiguration.Appearance + ) -> NSColor { + let paneSurface = compositedColor( + nsColorPaneBackground(for: appearance), + over: .windowBackgroundColor + ) + return compositedColor( + nsColorBarBackground(for: appearance), + over: paneSurface ) } + private static func resolvedTabBackgroundColor( + from value: String?, + isSelected: Bool, + isHovered: Bool + ) -> NSColor? { + guard let custom = tabBackgroundColor(from: value) else { return nil } + guard isHovered && !isSelected else { return custom } + return custom.isBonsplitLightColor + ? custom.bonsplitDarken(by: 0.03) + : custom.bonsplitLighten(by: 0.07) + } + private static func effectiveTabTextColor( for appearance: BonsplitConfiguration.Appearance, backgroundHex: String?, + isSelected: Bool, + isHovered: Bool, secondary: Bool ) -> NSColor { - guard let customBackground = tabBackgroundColor(from: backgroundHex) else { + guard let customBackground = resolvedTabBackgroundColor( + from: backgroundHex, + isSelected: isSelected, + isHovered: isHovered + ) else { return effectiveTextColor(for: appearance, secondary: secondary) } - let contrastBackground = compositedTabBackground( + let contrastBackground = compositedColor( customBackground, - over: nsColorBarBackground(for: appearance) + over: effectiveTabBarSurface(for: appearance) ) if contrastBackground.isBonsplitLightColor { let alpha = secondary ? Constants.darkSecondaryTextAlpha : Constants.darkTextAlpha @@ -254,12 +302,12 @@ enum TabBarColors { isSelected: Bool, isHovered: Bool ) -> Color { - if let custom = tabBackgroundColor(from: backgroundHex) { - guard isHovered && !isSelected else { return Color(nsColor: custom) } - let adjusted = custom.isBonsplitLightColor - ? custom.bonsplitDarken(by: 0.03) - : custom.bonsplitLighten(by: 0.07) - return Color(nsColor: adjusted) + if let custom = resolvedTabBackgroundColor( + from: backgroundHex, + isSelected: isSelected, + isHovered: isHovered + ) { + return Color(nsColor: custom) } if isSelected { return activeTabBackground(for: appearance) @@ -300,24 +348,59 @@ enum TabBarColors { static func tabText( for appearance: BonsplitConfiguration.Appearance, backgroundHex: String?, - isSelected: Bool + isSelected: Bool, + isHovered: Bool = false, + isSecondary: Bool? = nil ) -> Color { Color(nsColor: nsColorTabText( for: appearance, backgroundHex: backgroundHex, - isSelected: isSelected + isSelected: isSelected, + isHovered: isHovered, + isSecondary: isSecondary )) } static func nsColorTabText( for appearance: BonsplitConfiguration.Appearance, backgroundHex: String?, - isSelected: Bool + isSelected: Bool, + isHovered: Bool = false, + isSecondary: Bool? = nil ) -> NSColor { effectiveTabTextColor( for: appearance, backgroundHex: backgroundHex, - secondary: !isSelected + isSelected: isSelected, + isHovered: isHovered, + secondary: isSecondary ?? !isSelected + ) + } + + /// Resolves a matched foreground and overlay for controls drawn on a custom tab. + /// Returns `nil` so callers can preserve their standard, non-custom control styling. + static func customTabControlColors( + for appearance: BonsplitConfiguration.Appearance, + backgroundHex: String?, + isSelected: Bool, + isHovered: Bool, + prominent: Bool = false + ) -> TabControlColorPair? { + guard resolvedTabBackgroundColor( + from: backgroundHex, + isSelected: isSelected, + isHovered: isHovered + ) != nil else { return nil } + let foreground = effectiveTabTextColor( + for: appearance, + backgroundHex: backgroundHex, + isSelected: isSelected, + isHovered: isHovered, + secondary: false + ) + return TabControlColorPair( + foreground: Color(nsColor: foreground), + background: Color(nsColor: foreground.withAlphaComponent(prominent ? 0.22 : 0.14)) ) } diff --git a/Sources/Bonsplit/Internal/Views/TabItemView.swift b/Sources/Bonsplit/Internal/Views/TabItemView.swift index 629125c0..13cfb53f 100644 --- a/Sources/Bonsplit/Internal/Views/TabItemView.swift +++ b/Sources/Bonsplit/Internal/Views/TabItemView.swift @@ -402,7 +402,9 @@ struct TabItemView: View { TabBarColors.tabText( for: appearance, backgroundHex: tab.backgroundHex, - isSelected: true + isSelected: isSelected, + isHovered: isHovered, + isSecondary: false ) } @@ -410,7 +412,9 @@ struct TabItemView: View { TabBarColors.tabText( for: appearance, backgroundHex: tab.backgroundHex, - isSelected: false + isSelected: isSelected, + isHovered: isHovered, + isSecondary: true ) } @@ -422,10 +426,34 @@ struct TabItemView: View { TabBarColors.nsColorTabText( for: appearance, backgroundHex: tab.backgroundHex, - isSelected: isSelected + isSelected: isSelected, + isHovered: isHovered + ) + } + + private var customHoverControlColors: TabBarColors.TabControlColorPair? { + TabBarColors.customTabControlColors( + for: appearance, + backgroundHex: tab.backgroundHex, + isSelected: isSelected, + isHovered: isHovered + ) + } + + private var customPinnedBadgeColors: TabBarColors.TabControlColorPair? { + TabBarColors.customTabControlColors( + for: appearance, + backgroundHex: tab.backgroundHex, + isSelected: isSelected, + isHovered: isHovered, + prominent: true ) } + private var secondaryAccessoryText: Color { + customHoverControlColors == nil ? currentTabText.opacity(0.78) : subduedTabText + } + /// Whether this tab renders in the compact icon-only style reserved for pinned /// browser surfaces (favicon only, no title or trailing affordances). private var isIconOnlyPinned: Bool { @@ -575,9 +603,7 @@ struct TabItemView: View { if tab.showsRemoteIndicator { Image(systemName: "network") .font(.system(size: accessoryFontSize, weight: .semibold)) - .foregroundStyle( - currentTabText.opacity(0.78) - ) + .foregroundStyle(secondaryAccessoryText) .saturation(saturation) .accessibilityHidden(true) } @@ -601,15 +627,16 @@ struct TabItemView: View { .font(.system(size: accessoryFontSize, weight: .semibold)) .foregroundStyle( isAudioHovered - ? currentTabText - : currentTabText.opacity(0.78) + ? (customHoverControlColors?.foreground ?? currentTabText) + : secondaryAccessoryText ) .frame(width: accessorySlotSize, height: accessorySlotSize) .background( Circle() .fill( isAudioHovered - ? TabBarColors.hoveredTabBackground(for: appearance) + ? (customHoverControlColors?.background + ?? TabBarColors.hoveredTabBackground(for: appearance)) : .clear ) ) @@ -634,7 +661,7 @@ struct TabItemView: View { .font(.system(size: accessoryFontSize, weight: .semibold)) .foregroundStyle( isZoomHovered - ? emphasizedTabText + ? (customHoverControlColors?.foreground ?? emphasizedTabText) : subduedTabText ) .frame(width: accessorySlotSize, height: accessorySlotSize) @@ -642,7 +669,8 @@ struct TabItemView: View { Circle() .fill( isZoomHovered - ? TabBarColors.hoveredTabBackground(for: appearance) + ? (customHoverControlColors?.background + ?? TabBarColors.hoveredTabBackground(for: appearance)) : .clear ) ) @@ -785,10 +813,13 @@ struct TabItemView: View { } label: { Image(systemName: isMuted ? "speaker.slash.fill" : "speaker.wave.2.fill") .font(.system(size: max(6, accessoryFontSize - 4), weight: .semibold)) - .foregroundStyle(currentTabText) + .foregroundStyle(customPinnedBadgeColors?.foreground ?? currentTabText) .padding(2) .background( - Circle().fill(TabBarColors.activeTabBackground(for: appearance)) + Circle().fill( + customPinnedBadgeColors?.background + ?? TabBarColors.activeTabBackground(for: appearance) + ) ) .contentShape(Circle()) } @@ -1069,7 +1100,7 @@ struct TabItemView: View { .font(.system(size: scaledCloseIconSize, weight: .semibold)) .foregroundStyle( isCloseHovered - ? emphasizedTabText + ? (customHoverControlColors?.foreground ?? emphasizedTabText) : subduedTabText ) .frame(width: accessorySlotSize, height: accessorySlotSize) @@ -1077,7 +1108,8 @@ struct TabItemView: View { Circle() .fill( isCloseHovered - ? TabBarColors.hoveredTabBackground(for: appearance) + ? (customHoverControlColors?.background + ?? TabBarColors.hoveredTabBackground(for: appearance)) : .clear ) ) diff --git a/Tests/BonsplitTests/BonsplitTests.swift b/Tests/BonsplitTests/BonsplitTests.swift index a2367d5f..2f89bbf1 100644 --- a/Tests/BonsplitTests/BonsplitTests.swift +++ b/Tests/BonsplitTests/BonsplitTests.swift @@ -1217,6 +1217,59 @@ final class BonsplitTests: XCTestCase { XCTAssertGreaterThan(foreground.blueComponent, 0.9) } + func testTranslucentPerTabBackgroundUsesCompositedTabBarSurfaceForContrast() { + let appearance = BonsplitConfiguration.Appearance( + chromeColors: .init( + tabBarBackgroundHex: "#FFFFFF1A", + paneBackgroundHex: "#000000" + ) + ) + + let foreground = TabBarColors.nsColorTabText( + for: appearance, + backgroundHex: "#FFFFFF66", + isSelected: true + ).usingColorSpace(.sRGB)! + + XCTAssertGreaterThan(foreground.redComponent, 0.9) + XCTAssertGreaterThan(foreground.greenComponent, 0.9) + XCTAssertGreaterThan(foreground.blueComponent, 0.9) + } + + func testHoveredPerTabBackgroundReevaluatesForegroundContrast() { + let appearance = BonsplitConfiguration.Appearance() + let resting = TabBarColors.nsColorTabText( + for: appearance, + backgroundHex: "#737373", + isSelected: false, + isHovered: false + ).usingColorSpace(.sRGB)! + let hovered = TabBarColors.nsColorTabText( + for: appearance, + backgroundHex: "#737373", + isSelected: false, + isHovered: true + ).usingColorSpace(.sRGB)! + + XCTAssertGreaterThan(resting.redComponent, 0.9) + XCTAssertLessThan(hovered.redComponent, 0.1) + } + + func testCustomTabControlColorsUseOneContrastResolvedTone() { + let pair = TabBarColors.customTabControlColors( + for: BonsplitConfiguration.Appearance(), + backgroundHex: "#112233", + isSelected: true, + isHovered: false + )! + let foreground = NSColor(pair.foreground).usingColorSpace(.sRGB)! + let background = NSColor(pair.background).usingColorSpace(.sRGB)! + + XCTAssertGreaterThan(foreground.redComponent, 0.9) + XCTAssertGreaterThan(background.redComponent, 0.9) + XCTAssertEqual(background.alphaComponent, 0.14, accuracy: 0.001) + } + func testInvalidPerTabBackgroundFallsBackToStandardSelectedStyle() { let appearance = BonsplitConfiguration.Appearance() let invalid = NSColor(TabBarColors.tabBackground( From 3d0185f5e4ec0190fc46a054ec533acde335e250 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:36:10 -0700 Subject: [PATCH 03/10] Preserve transparent tab backgrounds --- .../Internal/Styling/TabBarColors.swift | 2 +- Tests/BonsplitTests/BonsplitTests.swift | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/Sources/Bonsplit/Internal/Styling/TabBarColors.swift b/Sources/Bonsplit/Internal/Styling/TabBarColors.swift index 819b000d..3f3a1685 100644 --- a/Sources/Bonsplit/Internal/Styling/TabBarColors.swift +++ b/Sources/Bonsplit/Internal/Styling/TabBarColors.swift @@ -113,7 +113,7 @@ enum TabBarColors { private static func tabBackgroundColor(from value: String?) -> NSColor? { guard let value else { return nil } - return nonClearColor(NSColor(bonsplitHex: value)) + return NSColor(bonsplitHex: value) } private static func compositedColor( diff --git a/Tests/BonsplitTests/BonsplitTests.swift b/Tests/BonsplitTests/BonsplitTests.swift index 2f89bbf1..2c2decb9 100644 --- a/Tests/BonsplitTests/BonsplitTests.swift +++ b/Tests/BonsplitTests/BonsplitTests.swift @@ -1288,6 +1288,24 @@ final class BonsplitTests: XCTestCase { XCTAssertEqual(invalid.alphaComponent, standard.alphaComponent, accuracy: 0.0001) } + func testTransparentPerTabBackgroundRemainsExplicit() { + let appearance = BonsplitConfiguration.Appearance() + let transparent = NSColor(TabBarColors.tabBackground( + for: appearance, + backgroundHex: "#33669900", + isSelected: true, + isHovered: false + )).usingColorSpace(.sRGB)! + + XCTAssertEqual(transparent.alphaComponent, 0, accuracy: 0.0001) + XCTAssertNotNil(TabBarColors.customTabControlColors( + for: appearance, + backgroundHex: "#33669900", + isSelected: true, + isHovered: false + )) + } + func testPaneBackgroundHexOverrideCanDifferFromChromeBackground() { let appearance = BonsplitConfiguration.Appearance( chromeColors: .init( From cd1ab03742f2719a70dbc278bc55cb65ce18658e Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 1 Aug 2026 02:10:11 -0700 Subject: [PATCH 04/10] Add tab appearance customization menu --- README.md | 4 + .../Bonsplit/Internal/Models/TabItem.swift | 20 ++ .../Internal/Styling/TabAppearanceColor.swift | 60 +++++ .../Internal/Styling/TabBarColors.swift | 40 +-- .../Views/TabAppearanceCustomizerView.swift | 239 ++++++++++++++++++ .../Bonsplit/Internal/Views/TabBarView.swift | 7 + .../Views/TabContextMenuPresenter.swift | 8 + .../Internal/Views/TabDragPreview.swift | 8 +- .../Bonsplit/Internal/Views/TabItemView.swift | 54 +++- .../Bonsplit/Public/BonsplitController.swift | 24 ++ Sources/Bonsplit/Public/Types/Tab.swift | 10 + .../Resources/en.lproj/Localizable.strings | 13 + .../Resources/ja.lproj/Localizable.strings | 13 + Tests/BonsplitTests/BonsplitTests.swift | 88 ++++++- 14 files changed, 538 insertions(+), 50 deletions(-) create mode 100644 Sources/Bonsplit/Internal/Styling/TabAppearanceColor.swift create mode 100644 Sources/Bonsplit/Internal/Views/TabAppearanceCustomizerView.swift diff --git a/README.md b/README.md index 42bdf292..409e4e8c 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,8 @@ controller.selectPreviousTab() controller.selectNextTab() ``` +When tab context menus are enabled, **Customize Tab…** opens an icon and background editor. Bonsplit stores those choices in `iconOverride` and `backgroundHexOverride`, so resetting the customization restores the host-provided icon, favicon, and background. + #### Split Operations ```swift @@ -204,6 +206,8 @@ public struct Tab { public let title: String public let icon: String? public let backgroundHex: String? + public let iconOverride: String? + public let backgroundHexOverride: String? public let isDirty: Bool } ``` diff --git a/Sources/Bonsplit/Internal/Models/TabItem.swift b/Sources/Bonsplit/Internal/Models/TabItem.swift index 9e793811..97af937e 100644 --- a/Sources/Bonsplit/Internal/Models/TabItem.swift +++ b/Sources/Bonsplit/Internal/Models/TabItem.swift @@ -22,6 +22,8 @@ struct TabItem: Identifiable, Hashable, Codable { var iconImageData: Data? var iconAsset: String? var backgroundHex: String? + var iconOverride: String? + var backgroundHexOverride: String? var kind: String? var isDirty: Bool var showsNotificationBadge: Bool @@ -33,6 +35,14 @@ struct TabItem: Identifiable, Hashable, Codable { var isPinned: Bool var showsRemoteIndicator: Bool + var resolvedIconName: String? { + iconOverride ?? icon + } + + var resolvedBackgroundHex: String? { + backgroundHexOverride ?? backgroundHex + } + init( id: UUID = UUID(), title: String, @@ -41,6 +51,8 @@ struct TabItem: Identifiable, Hashable, Codable { iconImageData: Data? = nil, iconAsset: String? = nil, backgroundHex: String? = nil, + iconOverride: String? = nil, + backgroundHexOverride: String? = nil, kind: String? = nil, isDirty: Bool = false, showsNotificationBadge: Bool = false, @@ -57,6 +69,8 @@ struct TabItem: Identifiable, Hashable, Codable { self.iconImageData = iconImageData self.iconAsset = iconAsset self.backgroundHex = backgroundHex + self.iconOverride = iconOverride + self.backgroundHexOverride = backgroundHexOverride self.kind = kind self.isDirty = isDirty self.showsNotificationBadge = showsNotificationBadge @@ -83,6 +97,8 @@ struct TabItem: Identifiable, Hashable, Codable { case iconImageData case iconAsset case backgroundHex + case iconOverride + case backgroundHexOverride case kind case isDirty case showsNotificationBadge @@ -102,6 +118,8 @@ struct TabItem: Identifiable, Hashable, Codable { self.iconImageData = try c.decodeIfPresent(Data.self, forKey: .iconImageData) self.iconAsset = try c.decodeIfPresent(String.self, forKey: .iconAsset) self.backgroundHex = try c.decodeIfPresent(String.self, forKey: .backgroundHex) + self.iconOverride = try c.decodeIfPresent(String.self, forKey: .iconOverride) + self.backgroundHexOverride = try c.decodeIfPresent(String.self, forKey: .backgroundHexOverride) self.kind = try c.decodeIfPresent(String.self, forKey: .kind) self.isDirty = try c.decodeIfPresent(Bool.self, forKey: .isDirty) ?? false self.showsNotificationBadge = try c.decodeIfPresent(Bool.self, forKey: .showsNotificationBadge) ?? false @@ -121,6 +139,8 @@ struct TabItem: Identifiable, Hashable, Codable { try c.encodeIfPresent(iconImageData, forKey: .iconImageData) try c.encodeIfPresent(iconAsset, forKey: .iconAsset) try c.encodeIfPresent(backgroundHex, forKey: .backgroundHex) + try c.encodeIfPresent(iconOverride, forKey: .iconOverride) + try c.encodeIfPresent(backgroundHexOverride, forKey: .backgroundHexOverride) try c.encodeIfPresent(kind, forKey: .kind) try c.encode(isDirty, forKey: .isDirty) try c.encode(showsNotificationBadge, forKey: .showsNotificationBadge) diff --git a/Sources/Bonsplit/Internal/Styling/TabAppearanceColor.swift b/Sources/Bonsplit/Internal/Styling/TabAppearanceColor.swift new file mode 100644 index 00000000..479f3c8a --- /dev/null +++ b/Sources/Bonsplit/Internal/Styling/TabAppearanceColor.swift @@ -0,0 +1,60 @@ +import AppKit +import Foundation +import SwiftUI + +enum TabAppearanceColor { + private static let hexDigits = CharacterSet(charactersIn: "0123456789abcdefABCDEF") + + static func nsColor(hex value: String) -> NSColor? { + var hex = value.trimmingCharacters(in: .whitespacesAndNewlines) + if hex.hasPrefix("#") { + hex.removeFirst() + } + guard hex.count == 6 || hex.count == 8 else { return nil } + guard hex.unicodeScalars.allSatisfy({ hexDigits.contains($0) }) else { return nil } + guard let rgba = UInt64(hex, radix: 16) else { return nil } + + let red: CGFloat + let green: CGFloat + let blue: CGFloat + let alpha: CGFloat + if hex.count == 8 { + red = CGFloat((rgba & 0xFF000000) >> 24) / 255.0 + green = CGFloat((rgba & 0x00FF0000) >> 16) / 255.0 + blue = CGFloat((rgba & 0x0000FF00) >> 8) / 255.0 + alpha = CGFloat(rgba & 0x000000FF) / 255.0 + } else { + red = CGFloat((rgba & 0xFF0000) >> 16) / 255.0 + green = CGFloat((rgba & 0x00FF00) >> 8) / 255.0 + blue = CGFloat(rgba & 0x0000FF) / 255.0 + alpha = 1.0 + } + return NSColor(red: red, green: green, blue: blue, alpha: alpha) + } + + static func color(hex value: String?) -> Color? { + guard let value, let color = nsColor(hex: value) else { return nil } + return Color(nsColor: color) + } + + static func hex(from color: Color) -> String? { + hex(from: NSColor(color)) + } + + static func hex(from color: NSColor) -> String? { + guard let color = color.usingColorSpace(.sRGB) else { return nil } + let red = byte(from: color.redComponent) + let green = byte(from: color.greenComponent) + let blue = byte(from: color.blueComponent) + let alpha = byte(from: color.alphaComponent) + + if alpha == 255 { + return String(format: "#%02X%02X%02X", red, green, blue) + } + return String(format: "#%02X%02X%02X%02X", red, green, blue, alpha) + } + + private static func byte(from component: CGFloat) -> Int { + Int((min(max(component, 0), 1) * 255).rounded()) + } +} diff --git a/Sources/Bonsplit/Internal/Styling/TabBarColors.swift b/Sources/Bonsplit/Internal/Styling/TabBarColors.swift index 3f3a1685..ce990776 100644 --- a/Sources/Bonsplit/Internal/Styling/TabBarColors.swift +++ b/Sources/Bonsplit/Internal/Styling/TabBarColors.swift @@ -19,7 +19,7 @@ enum TabBarColors { for appearance: BonsplitConfiguration.Appearance ) -> NSColor? { guard let value = appearance.chromeColors.backgroundHex else { return nil } - return NSColor(bonsplitHex: value) + return TabAppearanceColor.nsColor(hex: value) } private static func paneBackgroundColor( @@ -28,7 +28,7 @@ enum TabBarColors { guard let value = appearance.chromeColors.paneBackgroundHex else { return chromeBackgroundColor(for: appearance) } - return NSColor(bonsplitHex: value) + return TabAppearanceColor.nsColor(hex: value) } private static func tabBarBackgroundColor( @@ -37,7 +37,7 @@ enum TabBarColors { guard let value = appearance.chromeColors.tabBarBackgroundHex else { return chromeBackgroundColor(for: appearance) } - return NSColor(bonsplitHex: value) + return TabAppearanceColor.nsColor(hex: value) } private static func nonClearColor(_ color: NSColor?) -> NSColor? { @@ -59,14 +59,14 @@ enum TabBarColors { guard let value = appearance.chromeColors.splitButtonBackdropHex else { return tabBarBackgroundColor(for: appearance) } - return NSColor(bonsplitHex: value) + return TabAppearanceColor.nsColor(hex: value) } private static func chromeBorderColor( for appearance: BonsplitConfiguration.Appearance ) -> NSColor? { guard let value = appearance.chromeColors.borderHex else { return nil } - return NSColor(bonsplitHex: value) + return TabAppearanceColor.nsColor(hex: value) } private static func effectiveBackgroundColor( @@ -113,7 +113,7 @@ enum TabBarColors { private static func tabBackgroundColor(from value: String?) -> NSColor? { guard let value else { return nil } - return NSColor(bonsplitHex: value) + return TabAppearanceColor.nsColor(hex: value) } private static func compositedColor( @@ -487,34 +487,6 @@ enum TabBarColors { } private extension NSColor { - private static let bonsplitHexDigits = CharacterSet(charactersIn: "0123456789abcdefABCDEF") - - convenience init?(bonsplitHex value: String) { - var hex = value.trimmingCharacters(in: .whitespacesAndNewlines) - if hex.hasPrefix("#") { - hex.removeFirst() - } - guard hex.count == 6 || hex.count == 8 else { return nil } - guard hex.unicodeScalars.allSatisfy({ Self.bonsplitHexDigits.contains($0) }) else { return nil } - guard let rgba = UInt64(hex, radix: 16) else { return nil } - let red: CGFloat - let green: CGFloat - let blue: CGFloat - let alpha: CGFloat - if hex.count == 8 { - red = CGFloat((rgba & 0xFF000000) >> 24) / 255.0 - green = CGFloat((rgba & 0x00FF0000) >> 16) / 255.0 - blue = CGFloat((rgba & 0x0000FF00) >> 8) / 255.0 - alpha = CGFloat(rgba & 0x000000FF) / 255.0 - } else { - red = CGFloat((rgba & 0xFF0000) >> 16) / 255.0 - green = CGFloat((rgba & 0x00FF00) >> 8) / 255.0 - blue = CGFloat(rgba & 0x0000FF) / 255.0 - alpha = 1.0 - } - self.init(red: red, green: green, blue: blue, alpha: alpha) - } - var isBonsplitLightColor: Bool { var red: CGFloat = 0 var green: CGFloat = 0 diff --git a/Sources/Bonsplit/Internal/Views/TabAppearanceCustomizerView.swift b/Sources/Bonsplit/Internal/Views/TabAppearanceCustomizerView.swift new file mode 100644 index 00000000..529086e4 --- /dev/null +++ b/Sources/Bonsplit/Internal/Views/TabAppearanceCustomizerView.swift @@ -0,0 +1,239 @@ +import AppKit +import SwiftUI + +struct TabAppearanceCustomizerView: View { + private static let suggestedIcons = [ + "terminal.fill", + "globe", + "folder.fill", + "doc.text.fill", + "bolt.fill", + "star.fill", + "paintbrush.fill", + "checkmark.circle.fill", + ] + + let tabTitle: String + let fallbackIconName: String? + let baseBackgroundHex: String? + let appearance: BonsplitConfiguration.Appearance + let onApply: (_ iconOverride: String?, _ backgroundHexOverride: String?) -> Void + + @Environment(\.dismiss) private var dismiss + @State private var iconName: String + @State private var usesCustomBackground: Bool + @State private var backgroundColor: Color + + init( + tabTitle: String, + fallbackIconName: String?, + baseBackgroundHex: String?, + appearance: BonsplitConfiguration.Appearance, + iconOverride: String?, + backgroundHexOverride: String?, + onApply: @escaping (_ iconOverride: String?, _ backgroundHexOverride: String?) -> Void + ) { + self.tabTitle = tabTitle + self.fallbackIconName = fallbackIconName + self.baseBackgroundHex = baseBackgroundHex + self.appearance = appearance + self.onApply = onApply + _iconName = State(initialValue: iconOverride ?? "") + _usesCustomBackground = State(initialValue: backgroundHexOverride != nil) + _backgroundColor = State( + initialValue: TabAppearanceColor.color(hex: backgroundHexOverride) + ?? TabAppearanceColor.color(hex: baseBackgroundHex) + ?? Color(nsColor: .controlAccentColor) + ) + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text(localized("tabAppearance.title", defaultValue: "Customize Tab")) + .font(.headline) + + preview + + GroupBox { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + ForEach(Self.suggestedIcons, id: \.self) { icon in + suggestedIconButton(icon) + } + } + + TextField( + localized("tabAppearance.iconName", defaultValue: "SF Symbol name"), + text: $iconName + ) + .textFieldStyle(.roundedBorder) + .accessibilityIdentifier("bonsplit.tabAppearance.iconName") + + if !isIconValid { + Text(localized( + "tabAppearance.invalidIcon", + defaultValue: "This symbol is not available." + )) + .font(.caption) + .foregroundStyle(.red) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } label: { + Label( + localized("tabAppearance.icon", defaultValue: "Icon"), + systemImage: "app.dashed" + ) + } + + GroupBox { + VStack(alignment: .leading, spacing: 10) { + Toggle( + localized( + "tabAppearance.useCustomBackground", + defaultValue: "Use custom background" + ), + isOn: $usesCustomBackground + ) + + ColorPicker( + localized("tabAppearance.backgroundColor", defaultValue: "Background color"), + selection: $backgroundColor, + supportsOpacity: true + ) + .disabled(!usesCustomBackground) + .accessibilityIdentifier("bonsplit.tabAppearance.backgroundColor") + } + .frame(maxWidth: .infinity, alignment: .leading) + } label: { + Label( + localized("tabAppearance.background", defaultValue: "Background"), + systemImage: "paintpalette" + ) + } + + HStack { + Button(localized("tabAppearance.reset", defaultValue: "Reset")) { + iconName = "" + usesCustomBackground = false + } + .disabled(iconOverride == nil && !usesCustomBackground) + + Spacer() + + Button(localized("tabAppearance.cancel", defaultValue: "Cancel")) { + dismiss() + } + .keyboardShortcut(.cancelAction) + + Button(localized("tabAppearance.save", defaultValue: "Save")) { + onApply(iconOverride, backgroundHexOverride) + dismiss() + } + .keyboardShortcut(.defaultAction) + .disabled(!canSave) + .accessibilityIdentifier("bonsplit.tabAppearance.save") + } + } + .padding(20) + .frame(width: 420) + .accessibilityIdentifier("bonsplit.tabAppearance.customizer") + } + + private var preview: some View { + HStack(spacing: 8) { + Image(systemName: previewIconName) + .frame(width: 16, height: 16) + Text(tabTitle) + .lineLimit(1) + } + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(previewForeground) + .padding(.horizontal, 12) + .frame(maxWidth: .infinity, minHeight: 34, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .fill(previewBackground) + ) + .overlay( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .stroke(.separator.opacity(0.45), lineWidth: 1) + ) + .accessibilityLabel(localized("tabAppearance.preview", defaultValue: "Preview")) + } + + private func suggestedIconButton(_ icon: String) -> some View { + Button { + iconName = icon + } label: { + Image(systemName: icon) + .frame(width: 30, height: 28) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(iconOverride == icon ? Color.accentColor.opacity(0.18) : .clear) + ) + .overlay( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(iconOverride == icon ? Color.accentColor : .clear, lineWidth: 1) + ) + } + .buttonStyle(.plain) + .accessibilityLabel(String( + format: localized("tabAppearance.useIcon", defaultValue: "Use %@ icon"), + icon + )) + .help(icon) + } + + private var trimmedIconName: String { + iconName.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var iconOverride: String? { + trimmedIconName.isEmpty ? nil : trimmedIconName + } + + private var backgroundHexOverride: String? { + guard usesCustomBackground else { return nil } + return TabAppearanceColor.hex(from: backgroundColor) + } + + private var isIconValid: Bool { + guard let iconOverride else { return true } + return NSImage(systemSymbolName: iconOverride, accessibilityDescription: nil) != nil + } + + private var canSave: Bool { + isIconValid && (!usesCustomBackground || backgroundHexOverride != nil) + } + + private var previewIconName: String { + guard let iconOverride else { return fallbackIconName ?? "doc.text" } + return isIconValid ? iconOverride : "questionmark.square.dashed" + } + + private var previewBackground: Color { + TabBarColors.tabBackground( + for: appearance, + backgroundHex: previewBackgroundHex, + isSelected: true, + isHovered: false + ) + } + + private var previewForeground: Color { + TabBarColors.tabText( + for: appearance, + backgroundHex: previewBackgroundHex, + isSelected: true + ) + } + + private var previewBackgroundHex: String? { + usesCustomBackground ? backgroundHexOverride : baseBackgroundHex + } + + private func localized(_ key: String, defaultValue: String) -> String { + Bundle.module.localizedString(forKey: key, value: defaultValue, table: nil) + } +} diff --git a/Sources/Bonsplit/Internal/Views/TabBarView.swift b/Sources/Bonsplit/Internal/Views/TabBarView.swift index 16228635..42bf77fe 100644 --- a/Sources/Bonsplit/Internal/Views/TabBarView.swift +++ b/Sources/Bonsplit/Internal/Views/TabBarView.swift @@ -1289,6 +1289,13 @@ struct TabBarView: View { }, onMoveDestination: { destinationId in controller.requestTabMove(toDestination: destinationId, for: TabID(id: tab.id), inPane: pane.id) + }, + onAppearanceUpdate: { iconOverride, backgroundHexOverride in + controller.updateTab( + TabID(id: tab.id), + iconOverride: .some(iconOverride), + backgroundHexOverride: .some(backgroundHexOverride) + ) } ) .background( diff --git a/Sources/Bonsplit/Internal/Views/TabContextMenuPresenter.swift b/Sources/Bonsplit/Internal/Views/TabContextMenuPresenter.swift index c74f80fa..fc66f929 100644 --- a/Sources/Bonsplit/Internal/Views/TabContextMenuPresenter.swift +++ b/Sources/Bonsplit/Internal/Views/TabContextMenuPresenter.swift @@ -26,6 +26,7 @@ struct TabContextMenuSnapshot { final class TabContextMenuActionTarget: NSObject { var onContextAction: ((TabContextAction) -> Void)? var onMoveDestination: ((String) -> Void)? + var onCustomizeAppearance: (() -> Void)? @objc func performContextAction(_ sender: NSMenuItem) { guard let rawValue = sender.representedObject as? String, @@ -39,6 +40,10 @@ final class TabContextMenuActionTarget: NSObject { guard let destinationId = sender.representedObject as? String else { return } onMoveDestination?(destinationId) } + + @objc func performCustomizeAppearance(_ sender: NSMenuItem) { + onCustomizeAppearance?() + } } @MainActor @@ -100,6 +105,7 @@ struct TabContextMenuPresenter: NSViewRepresentable { let snapshot: TabContextMenuSnapshot let onContextAction: (TabContextAction) -> Void let onMoveDestination: (String) -> Void + let onCustomizeAppearance: () -> Void @MainActor final class Coordinator { @@ -128,6 +134,7 @@ struct TabContextMenuPresenter: NSViewRepresentable { let coordinator = Coordinator(snapshot: snapshot) coordinator.actionTarget.onContextAction = onContextAction coordinator.actionTarget.onMoveDestination = onMoveDestination + coordinator.actionTarget.onCustomizeAppearance = onCustomizeAppearance return coordinator } @@ -159,5 +166,6 @@ struct TabContextMenuPresenter: NSViewRepresentable { context.coordinator.snapshot = snapshot context.coordinator.actionTarget.onContextAction = onContextAction context.coordinator.actionTarget.onMoveDestination = onMoveDestination + context.coordinator.actionTarget.onCustomizeAppearance = onCustomizeAppearance } } diff --git a/Sources/Bonsplit/Internal/Views/TabDragPreview.swift b/Sources/Bonsplit/Internal/Views/TabDragPreview.swift index aab09a13..f2e619c5 100644 --- a/Sources/Bonsplit/Internal/Views/TabDragPreview.swift +++ b/Sources/Bonsplit/Internal/Views/TabDragPreview.swift @@ -7,12 +7,12 @@ struct TabDragPreview: View { var body: some View { HStack(spacing: TabBarMetrics.contentSpacing) { - if let iconName = tab.icon { + if let iconName = tab.resolvedIconName { Image(systemName: iconName) .font(.system(size: TabBarMetrics.iconSize)) .foregroundStyle(TabBarColors.tabText( for: appearance, - backgroundHex: tab.backgroundHex, + backgroundHex: tab.resolvedBackgroundHex, isSelected: true )) } @@ -22,7 +22,7 @@ struct TabDragPreview: View { .lineLimit(1) .foregroundStyle(TabBarColors.tabText( for: appearance, - backgroundHex: tab.backgroundHex, + backgroundHex: tab.resolvedBackgroundHex, isSelected: true )) } @@ -32,7 +32,7 @@ struct TabDragPreview: View { RoundedRectangle(cornerRadius: TabBarMetrics.tabCornerRadius, style: .continuous) .fill(TabBarColors.tabBackground( for: appearance, - backgroundHex: tab.backgroundHex, + backgroundHex: tab.resolvedBackgroundHex, isSelected: true, isHovered: false )) diff --git a/Sources/Bonsplit/Internal/Views/TabItemView.swift b/Sources/Bonsplit/Internal/Views/TabItemView.swift index 13cfb53f..0e0294b9 100644 --- a/Sources/Bonsplit/Internal/Views/TabItemView.swift +++ b/Sources/Bonsplit/Internal/Views/TabItemView.swift @@ -292,6 +292,7 @@ struct TabItemView: View { let onZoomToggle: () -> Void let onContextAction: (TabContextAction) -> Void let onMoveDestination: (String) -> Void + let onAppearanceUpdate: (_ iconOverride: String?, _ backgroundHexOverride: String?) -> Void @State private var isHovered = false @State private var isCloseHovered = false @@ -303,6 +304,7 @@ struct TabItemView: View { @State private var lastLoadingStoppedAt: Date? @State private var renderedFaviconData: Data? @State private var renderedFaviconImage: NSImage? + @State private var isAppearanceCustomizerPresented = false #if DEBUG @State private var debugLastIconFrame: CGRect? @State private var debugLastTitleFrame: CGRect? @@ -356,10 +358,24 @@ struct TabItemView: View { forkConversationAvailabilityRefreshHandler: forkConversationAvailabilityRefreshHandler ), onContextAction: onContextAction, - onMoveDestination: onMoveDestination + onMoveDestination: onMoveDestination, + onCustomizeAppearance: { + isAppearanceCustomizerPresented = true + } ) } } + .sheet(isPresented: $isAppearanceCustomizerPresented) { + TabAppearanceCustomizerView( + tabTitle: tab.title, + fallbackIconName: tab.icon, + baseBackgroundHex: tab.backgroundHex, + appearance: appearance, + iconOverride: tab.iconOverride, + backgroundHexOverride: tab.backgroundHexOverride, + onApply: onAppearanceUpdate + ) + } .onTapGesture { onSelect() } @@ -401,7 +417,7 @@ struct TabItemView: View { private var emphasizedTabText: Color { TabBarColors.tabText( for: appearance, - backgroundHex: tab.backgroundHex, + backgroundHex: tab.resolvedBackgroundHex, isSelected: isSelected, isHovered: isHovered, isSecondary: false @@ -411,7 +427,7 @@ struct TabItemView: View { private var subduedTabText: Color { TabBarColors.tabText( for: appearance, - backgroundHex: tab.backgroundHex, + backgroundHex: tab.resolvedBackgroundHex, isSelected: isSelected, isHovered: isHovered, isSecondary: true @@ -425,7 +441,7 @@ struct TabItemView: View { private var currentTabTextNSColor: NSColor { TabBarColors.nsColorTabText( for: appearance, - backgroundHex: tab.backgroundHex, + backgroundHex: tab.resolvedBackgroundHex, isSelected: isSelected, isHovered: isHovered ) @@ -434,7 +450,7 @@ struct TabItemView: View { private var customHoverControlColors: TabBarColors.TabControlColorPair? { TabBarColors.customTabControlColors( for: appearance, - backgroundHex: tab.backgroundHex, + backgroundHex: tab.resolvedBackgroundHex, isSelected: isSelected, isHovered: isHovered ) @@ -443,7 +459,7 @@ struct TabItemView: View { private var customPinnedBadgeColors: TabBarColors.TabControlColorPair? { TabBarColors.customTabControlColors( for: appearance, - backgroundHex: tab.backgroundHex, + backgroundHex: tab.resolvedBackgroundHex, isSelected: isSelected, isHovered: isHovered, prominent: true @@ -744,6 +760,10 @@ struct TabItemView: View { if tab.isLoading { // Slightly smaller than the icon slot so it reads cleaner at tab scale. TabLoadingSpinner(size: iconSlotSize * 0.86, color: iconTintColor) + } else if let iconOverride = tab.iconOverride { + Image(systemName: iconOverride) + .font(.system(size: glyphSize(for: iconOverride))) + .foregroundStyle(iconTint) } else if let image = faviconImage { FaviconIconView(image: image) .frame(width: iconSlotSize, height: iconSlotSize, alignment: .center) @@ -768,7 +788,10 @@ struct TabItemView: View { } } // Keep downloaded favicon bitmaps in full color even for inactive tab bars. - .saturation(TabItemStyling.iconSaturation(hasRasterIcon: faviconImage != nil, tabSaturation: saturation)) + .saturation(TabItemStyling.iconSaturation( + hasRasterIcon: tab.iconOverride == nil && faviconImage != nil, + tabSaturation: saturation + )) .transaction { tx in // Prevent incidental parent animations from briefly fading icon content. tx.animation = nil @@ -785,12 +808,13 @@ struct TabItemView: View { globeFallbackWorkItem?.cancel() globeFallbackWorkItem = nil } - .onChange(of: tab.isLoading) { _ in updateGlobeFallback() } - .onChange(of: tab.iconImageData) { _ in + .onChange(of: tab.isLoading) { updateGlobeFallback() } + .onChange(of: tab.iconImageData) { updateRenderedFaviconImage() updateGlobeFallback() } - .onChange(of: tab.icon) { _ in updateGlobeFallback() } + .onChange(of: tab.icon) { updateGlobeFallback() } + .onChange(of: tab.iconOverride) { updateGlobeFallback() } } /// Small corner badge for icon-only pinned tabs, preserving the audio/unread/ @@ -1042,7 +1066,7 @@ struct TabItemView: View { Rectangle() .fill(TabBarColors.tabBackground( for: appearance, - backgroundHex: tab.backgroundHex, + backgroundHex: tab.resolvedBackgroundHex, isSelected: isSelected, isHovered: TabItemStyling.shouldShowHoverBackground( isHovered: isHovered, @@ -1420,6 +1444,14 @@ enum TabContextMenuBuilder { to: menu ) + let customizeAppearanceItem = NSMenuItem( + title: localized("tabContext.customizeAppearance", defaultValue: "Customize Tab…"), + action: #selector(TabContextMenuActionTarget.performCustomizeAppearance(_:)), + keyEquivalent: "" + ) + customizeAppearanceItem.target = target + menu.addItem(customizeAppearanceItem) + if state.hasCustomTitle { addAction( title: localized("tabContext.removeCustomTabName", defaultValue: "Remove Custom Tab Name"), diff --git a/Sources/Bonsplit/Public/BonsplitController.swift b/Sources/Bonsplit/Public/BonsplitController.swift index 15a70ab1..5c30eefb 100644 --- a/Sources/Bonsplit/Public/BonsplitController.swift +++ b/Sources/Bonsplit/Public/BonsplitController.swift @@ -163,6 +163,8 @@ public final class BonsplitController { /// - iconImageData: Optional image data (PNG recommended) for the tab icon. When present, takes precedence over `icon`. /// - iconAsset: Optional asset-catalog image name for the tab icon. Resolved in the host app bundle. /// - backgroundHex: Optional per-tab background color as `#RRGGBB` or `#RRGGBBAA`. + /// - iconOverride: Optional user-selected SF Symbol that takes precedence over host-provided icons. + /// - backgroundHexOverride: Optional user-selected background that takes precedence over `backgroundHex`. /// - kind: Consumer-defined tab kind identifier (e.g. "terminal", "browser") /// - hasCustomTitle: Whether the tab title came from a custom user override /// - isDirty: Whether the tab shows a dirty indicator @@ -180,6 +182,8 @@ public final class BonsplitController { iconImageData: Data? = nil, iconAsset: String? = nil, backgroundHex: String? = nil, + iconOverride: String? = nil, + backgroundHexOverride: String? = nil, kind: String? = nil, isDirty: Bool = false, showsNotificationBadge: Bool = false, @@ -199,6 +203,8 @@ public final class BonsplitController { iconImageData: iconImageData, iconAsset: iconAsset, backgroundHex: backgroundHex, + iconOverride: iconOverride, + backgroundHexOverride: backgroundHexOverride, kind: kind, isDirty: isDirty, showsNotificationBadge: showsNotificationBadge, @@ -241,6 +247,8 @@ public final class BonsplitController { iconImageData: iconImageData, iconAsset: iconAsset, backgroundHex: backgroundHex, + iconOverride: iconOverride, + backgroundHexOverride: backgroundHexOverride, kind: kind, isDirty: isDirty, showsNotificationBadge: showsNotificationBadge, @@ -294,6 +302,8 @@ public final class BonsplitController { /// - iconImageData: New icon image data (pass nil to keep current, pass .some(nil) to remove) /// - iconAsset: New asset-catalog icon name (pass nil to keep current, pass .some(nil) to remove) /// - backgroundHex: New per-tab background color (pass nil to keep current, pass .some(nil) to remove) + /// - iconOverride: New user-selected SF Symbol (pass nil to keep current, pass .some(nil) to restore the host icon) + /// - backgroundHexOverride: New user-selected background (pass nil to keep current, pass .some(nil) to restore `backgroundHex`) /// - kind: New tab kind (pass nil to keep current, pass .some(nil) to clear) /// - hasCustomTitle: New custom-title state (pass nil to keep current) /// - isDirty: New dirty state (pass nil to keep current) @@ -309,6 +319,8 @@ public final class BonsplitController { iconImageData: Data?? = nil, iconAsset: String?? = nil, backgroundHex: String?? = nil, + iconOverride: String?? = nil, + backgroundHexOverride: String?? = nil, kind: String?? = nil, hasCustomTitle: Bool? = nil, isDirty: Bool? = nil, @@ -327,6 +339,8 @@ public final class BonsplitController { iconImageData.map { currentTab.iconImageData != $0 } ?? false || iconAsset.map { currentTab.iconAsset != $0 } ?? false || backgroundHex.map { currentTab.backgroundHex != $0 } ?? false || + iconOverride.map { currentTab.iconOverride != $0 } ?? false || + backgroundHexOverride.map { currentTab.backgroundHexOverride != $0 } ?? false || kind.map { currentTab.kind != $0 } ?? false || hasCustomTitle.map { currentTab.hasCustomTitle != $0 } ?? false || isDirty.map { currentTab.isDirty != $0 } ?? false || @@ -353,6 +367,12 @@ public final class BonsplitController { if let backgroundHex = backgroundHex { pane.tabs[tabIndex].backgroundHex = backgroundHex } + if let iconOverride = iconOverride { + pane.tabs[tabIndex].iconOverride = iconOverride + } + if let backgroundHexOverride = backgroundHexOverride { + pane.tabs[tabIndex].backgroundHexOverride = backgroundHexOverride + } if let kind = kind { pane.tabs[tabIndex].kind = kind } @@ -544,6 +564,8 @@ public final class BonsplitController { iconImageData: tab.iconImageData, iconAsset: tab.iconAsset, backgroundHex: tab.backgroundHex, + iconOverride: tab.iconOverride, + backgroundHexOverride: tab.backgroundHexOverride, kind: tab.kind, isDirty: tab.isDirty, showsNotificationBadge: tab.showsNotificationBadge, @@ -614,6 +636,8 @@ public final class BonsplitController { iconImageData: tab.iconImageData, iconAsset: tab.iconAsset, backgroundHex: tab.backgroundHex, + iconOverride: tab.iconOverride, + backgroundHexOverride: tab.backgroundHexOverride, kind: tab.kind, isDirty: tab.isDirty, showsNotificationBadge: tab.showsNotificationBadge, diff --git a/Sources/Bonsplit/Public/Types/Tab.swift b/Sources/Bonsplit/Public/Types/Tab.swift index e50752fc..f42e65b5 100644 --- a/Sources/Bonsplit/Public/Types/Tab.swift +++ b/Sources/Bonsplit/Public/Types/Tab.swift @@ -12,6 +12,10 @@ public struct Tab: Identifiable, Hashable, Sendable { public let iconAsset: String? /// Optional per-tab background color as `#RRGGBB` or `#RRGGBBAA`. public let backgroundHex: String? + /// Optional SF Symbol selected by the user. When present, this takes precedence over all host-provided icons. + public let iconOverride: String? + /// Optional background selected by the user. When present, this takes precedence over `backgroundHex`. + public let backgroundHexOverride: String? /// Consumer-defined tab kind identifier (for example, "terminal" or "browser"). public let kind: String? public let isDirty: Bool @@ -37,6 +41,8 @@ public struct Tab: Identifiable, Hashable, Sendable { iconImageData: Data? = nil, iconAsset: String? = nil, backgroundHex: String? = nil, + iconOverride: String? = nil, + backgroundHexOverride: String? = nil, kind: String? = nil, isDirty: Bool = false, showsNotificationBadge: Bool = false, @@ -53,6 +59,8 @@ public struct Tab: Identifiable, Hashable, Sendable { self.iconImageData = iconImageData self.iconAsset = iconAsset self.backgroundHex = backgroundHex + self.iconOverride = iconOverride + self.backgroundHexOverride = backgroundHexOverride self.kind = kind self.isDirty = isDirty self.showsNotificationBadge = showsNotificationBadge @@ -71,6 +79,8 @@ public struct Tab: Identifiable, Hashable, Sendable { self.iconImageData = tabItem.iconImageData self.iconAsset = tabItem.iconAsset self.backgroundHex = tabItem.backgroundHex + self.iconOverride = tabItem.iconOverride + self.backgroundHexOverride = tabItem.backgroundHexOverride self.kind = tabItem.kind self.isDirty = tabItem.isDirty self.showsNotificationBadge = tabItem.showsNotificationBadge diff --git a/Sources/Bonsplit/Resources/en.lproj/Localizable.strings b/Sources/Bonsplit/Resources/en.lproj/Localizable.strings index 8fcd21e9..b64959f8 100644 --- a/Sources/Bonsplit/Resources/en.lproj/Localizable.strings +++ b/Sources/Bonsplit/Resources/en.lproj/Localizable.strings @@ -4,6 +4,7 @@ "command.moveTabToLeftPane.title" = "Move to Left Pane"; "command.moveTabToRightPane.title" = "Move to Right Pane"; "tabContext.renameTab" = "Rename Tab…"; +"tabContext.customizeAppearance" = "Customize Tab…"; "tabContext.removeCustomTabName" = "Remove Custom Tab Name"; "tabContext.closeTabsToLeft" = "Close Tabs to Left"; "tabContext.closeTabsToRight" = "Close Tabs to Right"; @@ -41,3 +42,15 @@ "tabContext.forkConversation.newWorkspace" = "New Workspace"; "tabContext.disconnectRemote" = "Disconnect SSH"; "tabContext.remoteConnectedAccessibility" = "Connected over SSH"; +"tabAppearance.title" = "Customize Tab"; +"tabAppearance.preview" = "Preview"; +"tabAppearance.icon" = "Icon"; +"tabAppearance.iconName" = "SF Symbol name"; +"tabAppearance.invalidIcon" = "This symbol is not available."; +"tabAppearance.useIcon" = "Use %@ icon"; +"tabAppearance.background" = "Background"; +"tabAppearance.useCustomBackground" = "Use custom background"; +"tabAppearance.backgroundColor" = "Background color"; +"tabAppearance.reset" = "Reset"; +"tabAppearance.cancel" = "Cancel"; +"tabAppearance.save" = "Save"; diff --git a/Sources/Bonsplit/Resources/ja.lproj/Localizable.strings b/Sources/Bonsplit/Resources/ja.lproj/Localizable.strings index 2364aa0d..2dcb6f25 100644 --- a/Sources/Bonsplit/Resources/ja.lproj/Localizable.strings +++ b/Sources/Bonsplit/Resources/ja.lproj/Localizable.strings @@ -4,6 +4,7 @@ "command.moveTabToLeftPane.title" = "左のペインへ移動"; "command.moveTabToRightPane.title" = "右のペインへ移動"; "tabContext.renameTab" = "タブ名を変更…"; +"tabContext.customizeAppearance" = "タブをカスタマイズ…"; "tabContext.removeCustomTabName" = "カスタムタブ名を削除"; "tabContext.closeTabsToLeft" = "左側のタブを閉じる"; "tabContext.closeTabsToRight" = "右側のタブを閉じる"; @@ -41,3 +42,15 @@ "tabContext.forkConversation.newWorkspace" = "新規ワークスペース"; "tabContext.disconnectRemote" = "SSH接続を解除"; "tabContext.remoteConnectedAccessibility" = "SSH接続中"; +"tabAppearance.title" = "タブをカスタマイズ"; +"tabAppearance.preview" = "プレビュー"; +"tabAppearance.icon" = "アイコン"; +"tabAppearance.iconName" = "SF Symbols名"; +"tabAppearance.invalidIcon" = "このシンボルは使用できません。"; +"tabAppearance.useIcon" = "%@アイコンを使用"; +"tabAppearance.background" = "背景"; +"tabAppearance.useCustomBackground" = "カスタム背景を使用"; +"tabAppearance.backgroundColor" = "背景色"; +"tabAppearance.reset" = "リセット"; +"tabAppearance.cancel" = "キャンセル"; +"tabAppearance.save" = "保存"; diff --git a/Tests/BonsplitTests/BonsplitTests.swift b/Tests/BonsplitTests/BonsplitTests.swift index 2c2decb9..dd113248 100644 --- a/Tests/BonsplitTests/BonsplitTests.swift +++ b/Tests/BonsplitTests/BonsplitTests.swift @@ -264,6 +264,65 @@ final class BonsplitTests: XCTestCase { XCTAssertEqual(tab.title, "Legacy") XCTAssertNil(tab.backgroundHex) + XCTAssertNil(tab.iconOverride) + XCTAssertNil(tab.backgroundHexOverride) + } + + @MainActor + func testTabAppearanceOverridesPreserveHostMetadataWhenReset() { + let controller = BonsplitController() + let favicon = Data([0x01, 0x02, 0x03]) + let tabId = controller.createTab( + title: "Customized", + icon: "globe", + iconImageData: favicon, + backgroundHex: "#112233" + )! + + controller.updateTab( + tabId, + iconOverride: .some("star.fill"), + backgroundHexOverride: .some("#AABBCC80") + ) + + var tab = controller.tab(tabId) + XCTAssertEqual(tab?.iconOverride, "star.fill") + XCTAssertEqual(tab?.backgroundHexOverride, "#AABBCC80") + XCTAssertEqual(tab?.icon, "globe") + XCTAssertEqual(tab?.iconImageData, favicon) + XCTAssertEqual(tab?.backgroundHex, "#112233") + + controller.updateTab( + tabId, + iconOverride: .some(nil), + backgroundHexOverride: .some(nil) + ) + + tab = controller.tab(tabId) + XCTAssertNil(tab?.iconOverride) + XCTAssertNil(tab?.backgroundHexOverride) + XCTAssertEqual(tab?.icon, "globe") + XCTAssertEqual(tab?.iconImageData, favicon) + XCTAssertEqual(tab?.backgroundHex, "#112233") + } + + func testTabItemResolvesUserAppearanceAheadOfHostAppearance() { + var tab = TabItem( + title: "Customized", + icon: "globe", + backgroundHex: "#112233", + iconOverride: "star.fill", + backgroundHexOverride: "#AABBCC" + ) + + XCTAssertEqual(tab.resolvedIconName, "star.fill") + XCTAssertEqual(tab.resolvedBackgroundHex, "#AABBCC") + + tab.iconOverride = nil + tab.backgroundHexOverride = nil + + XCTAssertEqual(tab.resolvedIconName, "globe") + XCTAssertEqual(tab.resolvedBackgroundHex, "#112233") } @MainActor @@ -275,6 +334,8 @@ final class BonsplitTests: XCTestCase { icon: "doc", iconAsset: "AgentIcons/Claude", backgroundHex: "#112233", + iconOverride: "star.fill", + backgroundHexOverride: "#445566", kind: "terminal", isDirty: true, showsNotificationBadge: true, @@ -297,6 +358,8 @@ final class BonsplitTests: XCTestCase { icon: .some("doc"), iconAsset: .some("AgentIcons/Claude"), backgroundHex: .some("#112233"), + iconOverride: .some("star.fill"), + backgroundHexOverride: .some("#445566"), kind: .some("terminal"), hasCustomTitle: true, isDirty: true, @@ -352,7 +415,9 @@ final class BonsplitTests: XCTestCase { let styled = Bonsplit.Tab( title: "Styled", icon: "paintbrush.fill", - backgroundHex: "#336699" + backgroundHex: "#336699", + iconOverride: "star.fill", + backgroundHexOverride: "#663399" ) let newPane = controller.splitPane(orientation: .horizontal, withTab: styled) @@ -360,6 +425,8 @@ final class BonsplitTests: XCTestCase { XCTAssertNotNil(newPane) XCTAssertEqual(controller.tab(styled.id)?.icon, "paintbrush.fill") XCTAssertEqual(controller.tab(styled.id)?.backgroundHex, "#336699") + XCTAssertEqual(controller.tab(styled.id)?.iconOverride, "star.fill") + XCTAssertEqual(controller.tab(styled.id)?.backgroundHexOverride, "#663399") } @MainActor @@ -1306,6 +1373,18 @@ final class BonsplitTests: XCTestCase { )) } + func testTabAppearanceColorParsesAndSerializesAlphaHex() throws { + let color = try XCTUnwrap(TabAppearanceColor.nsColor(hex: "#33669980")) + .usingColorSpace(.sRGB)! + + XCTAssertEqual(color.redComponent, 0x33 / 255, accuracy: 0.0001) + XCTAssertEqual(color.greenComponent, 0x66 / 255, accuracy: 0.0001) + XCTAssertEqual(color.blueComponent, 0x99 / 255, accuracy: 0.0001) + XCTAssertEqual(color.alphaComponent, 0x80 / 255, accuracy: 0.0001) + XCTAssertEqual(TabAppearanceColor.hex(from: color), "#33669980") + XCTAssertNil(TabAppearanceColor.nsColor(hex: "#not-a-color")) + } + func testPaneBackgroundHexOverrideCanDifferFromChromeBackground() { let appearance = BonsplitConfiguration.Appearance( chromeColors: .init( @@ -2007,8 +2086,10 @@ final class BonsplitTests: XCTestCase { let target = TabContextMenuActionTarget() var selectedAction: TabContextAction? var selectedDestinationId: String? + var customizeAppearanceRequestCount = 0 target.onContextAction = { selectedAction = $0 } target.onMoveDestination = { selectedDestinationId = $0 } + target.onCustomizeAppearance = { customizeAppearanceRequestCount += 1 } let state = TabContextMenuState( isPinned: false, isUnread: false, @@ -2042,8 +2123,10 @@ final class BonsplitTests: XCTestCase { let menu = TabContextMenuBuilder.makeMenu(snapshot: snapshot, target: target) let moveItem = menu.items.first { $0.title == "Move Tab" } + let customizeAppearanceItem = menu.items.first { $0.title == "Customize Tab…" } XCTAssertEqual(moveDestinationRequestCount, 1) + XCTAssertNotNil(customizeAppearanceItem) XCTAssertNotNil(moveItem) XCTAssertTrue(moveItem?.isEnabled ?? false) XCTAssertEqual(moveItem?.submenu?.items.map(\.title), ["Move Tab to New Workspace", "Workspace A"]) @@ -2056,6 +2139,9 @@ final class BonsplitTests: XCTestCase { let workspaceItem = try XCTUnwrap(moveItem?.submenu?.items.dropFirst().first) target.performMoveDestination(workspaceItem) XCTAssertEqual(selectedDestinationId, "workspace:abc") + + target.performCustomizeAppearance(try XCTUnwrap(customizeAppearanceItem)) + XCTAssertEqual(customizeAppearanceRequestCount, 1) } @MainActor From 81c9dea8d8ae45f82d5ac3fdee8c219c1448a4e9 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 1 Aug 2026 03:21:18 -0700 Subject: [PATCH 05/10] Refine tab icon and color customization --- README.md | 12 +- .../Bonsplit/Internal/Models/TabItem.swift | 20 +- .../Internal/Styling/TabAppearanceColor.swift | 25 ++ .../Views/TabAppearanceCustomizerView.swift | 239 ------------------ .../Bonsplit/Internal/Views/TabBarView.swift | 27 +- .../Internal/Views/TabColorPickerView.swift | 82 ++++++ .../Views/TabContextMenuPresenter.swift | 37 ++- .../Internal/Views/TabDragPreview.swift | 17 +- .../Internal/Views/TabIconCatalog.swift | 163 ++++++++++++ .../Views/TabIconCustomizerView.swift | 204 +++++++++++++++ .../Bonsplit/Internal/Views/TabItemView.swift | 142 +++++++++-- .../Bonsplit/Public/BonsplitController.swift | 35 ++- Sources/Bonsplit/Public/Types/Tab.swift | 10 +- .../Public/Types/TabColorPaletteEntry.swift | 44 ++++ .../Resources/en.lproj/Localizable.strings | 32 ++- .../Resources/ja.lproj/Localizable.strings | 32 ++- Tests/BonsplitTests/BonsplitTests.swift | 91 +++++-- 17 files changed, 867 insertions(+), 345 deletions(-) delete mode 100644 Sources/Bonsplit/Internal/Views/TabAppearanceCustomizerView.swift create mode 100644 Sources/Bonsplit/Internal/Views/TabColorPickerView.swift create mode 100644 Sources/Bonsplit/Internal/Views/TabIconCatalog.swift create mode 100644 Sources/Bonsplit/Internal/Views/TabIconCustomizerView.swift create mode 100644 Sources/Bonsplit/Public/Types/TabColorPaletteEntry.swift diff --git a/README.md b/README.md index 409e4e8c..cf4dd6d9 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,15 @@ controller.selectPreviousTab() controller.selectNextTab() ``` -When tab context menus are enabled, **Customize Tab…** opens an icon and background editor. Bonsplit stores those choices in `iconOverride` and `backgroundHexOverride`, so resetting the customization restores the host-provided icon, favicon, and background. +When tab context menus are enabled, **Tab Icon…** opens a searchable SF Symbol picker and **Tab Color** exposes a named palette plus a custom color picker. User colors render as a leading accent rail, while host-provided `backgroundHex` continues to control the full tab background. Bonsplit stores these choices in `iconOverride` and `colorHexOverride`, so clearing them restores the host-provided appearance. + +Hosts can keep the tab palette aligned with another color system by providing entries at menu-open time: + +```swift +controller.tabColorPaletteProvider = { + [TabColorPaletteEntry(name: "Blue", hex: "#1565C0")] +} +``` #### Split Operations @@ -207,7 +215,7 @@ public struct Tab { public let icon: String? public let backgroundHex: String? public let iconOverride: String? - public let backgroundHexOverride: String? + public let colorHexOverride: String? public let isDirty: Bool } ``` diff --git a/Sources/Bonsplit/Internal/Models/TabItem.swift b/Sources/Bonsplit/Internal/Models/TabItem.swift index 97af937e..b082e857 100644 --- a/Sources/Bonsplit/Internal/Models/TabItem.swift +++ b/Sources/Bonsplit/Internal/Models/TabItem.swift @@ -23,7 +23,7 @@ struct TabItem: Identifiable, Hashable, Codable { var iconAsset: String? var backgroundHex: String? var iconOverride: String? - var backgroundHexOverride: String? + var colorHexOverride: String? var kind: String? var isDirty: Bool var showsNotificationBadge: Bool @@ -39,10 +39,6 @@ struct TabItem: Identifiable, Hashable, Codable { iconOverride ?? icon } - var resolvedBackgroundHex: String? { - backgroundHexOverride ?? backgroundHex - } - init( id: UUID = UUID(), title: String, @@ -52,7 +48,7 @@ struct TabItem: Identifiable, Hashable, Codable { iconAsset: String? = nil, backgroundHex: String? = nil, iconOverride: String? = nil, - backgroundHexOverride: String? = nil, + colorHexOverride: String? = nil, kind: String? = nil, isDirty: Bool = false, showsNotificationBadge: Bool = false, @@ -70,7 +66,7 @@ struct TabItem: Identifiable, Hashable, Codable { self.iconAsset = iconAsset self.backgroundHex = backgroundHex self.iconOverride = iconOverride - self.backgroundHexOverride = backgroundHexOverride + self.colorHexOverride = colorHexOverride self.kind = kind self.isDirty = isDirty self.showsNotificationBadge = showsNotificationBadge @@ -98,6 +94,8 @@ struct TabItem: Identifiable, Hashable, Codable { case iconAsset case backgroundHex case iconOverride + case colorHexOverride + // Legacy dogfood builds stored the user color under this key. case backgroundHexOverride case kind case isDirty @@ -119,7 +117,11 @@ struct TabItem: Identifiable, Hashable, Codable { self.iconAsset = try c.decodeIfPresent(String.self, forKey: .iconAsset) self.backgroundHex = try c.decodeIfPresent(String.self, forKey: .backgroundHex) self.iconOverride = try c.decodeIfPresent(String.self, forKey: .iconOverride) - self.backgroundHexOverride = try c.decodeIfPresent(String.self, forKey: .backgroundHexOverride) + if let colorHexOverride = try c.decodeIfPresent(String.self, forKey: .colorHexOverride) { + self.colorHexOverride = colorHexOverride + } else { + self.colorHexOverride = try c.decodeIfPresent(String.self, forKey: .backgroundHexOverride) + } self.kind = try c.decodeIfPresent(String.self, forKey: .kind) self.isDirty = try c.decodeIfPresent(Bool.self, forKey: .isDirty) ?? false self.showsNotificationBadge = try c.decodeIfPresent(Bool.self, forKey: .showsNotificationBadge) ?? false @@ -140,7 +142,7 @@ struct TabItem: Identifiable, Hashable, Codable { try c.encodeIfPresent(iconAsset, forKey: .iconAsset) try c.encodeIfPresent(backgroundHex, forKey: .backgroundHex) try c.encodeIfPresent(iconOverride, forKey: .iconOverride) - try c.encodeIfPresent(backgroundHexOverride, forKey: .backgroundHexOverride) + try c.encodeIfPresent(colorHexOverride, forKey: .colorHexOverride) try c.encodeIfPresent(kind, forKey: .kind) try c.encode(isDirty, forKey: .isDirty) try c.encode(showsNotificationBadge, forKey: .showsNotificationBadge) diff --git a/Sources/Bonsplit/Internal/Styling/TabAppearanceColor.swift b/Sources/Bonsplit/Internal/Styling/TabAppearanceColor.swift index 479f3c8a..119db691 100644 --- a/Sources/Bonsplit/Internal/Styling/TabAppearanceColor.swift +++ b/Sources/Bonsplit/Internal/Styling/TabAppearanceColor.swift @@ -37,6 +37,31 @@ enum TabAppearanceColor { return Color(nsColor: color) } + static func railNSColor(hex value: String) -> NSColor? { + guard let color = nsColor(hex: value)?.usingColorSpace(.sRGB) else { return nil } + var hue: CGFloat = 0 + var saturation: CGFloat = 0 + var brightness: CGFloat = 0 + var alpha: CGFloat = 0 + color.getHue( + &hue, + saturation: &saturation, + brightness: &brightness, + alpha: &alpha + ) + + let boostedBrightness = min(1, max(brightness, 0.62) + ((1 - brightness) * 0.28)) + let boostedSaturation = saturation <= 0.08 + ? saturation + : min(1, saturation + ((1 - saturation) * 0.12)) + return NSColor( + hue: hue, + saturation: boostedSaturation, + brightness: boostedBrightness, + alpha: alpha + ) + } + static func hex(from color: Color) -> String? { hex(from: NSColor(color)) } diff --git a/Sources/Bonsplit/Internal/Views/TabAppearanceCustomizerView.swift b/Sources/Bonsplit/Internal/Views/TabAppearanceCustomizerView.swift deleted file mode 100644 index 529086e4..00000000 --- a/Sources/Bonsplit/Internal/Views/TabAppearanceCustomizerView.swift +++ /dev/null @@ -1,239 +0,0 @@ -import AppKit -import SwiftUI - -struct TabAppearanceCustomizerView: View { - private static let suggestedIcons = [ - "terminal.fill", - "globe", - "folder.fill", - "doc.text.fill", - "bolt.fill", - "star.fill", - "paintbrush.fill", - "checkmark.circle.fill", - ] - - let tabTitle: String - let fallbackIconName: String? - let baseBackgroundHex: String? - let appearance: BonsplitConfiguration.Appearance - let onApply: (_ iconOverride: String?, _ backgroundHexOverride: String?) -> Void - - @Environment(\.dismiss) private var dismiss - @State private var iconName: String - @State private var usesCustomBackground: Bool - @State private var backgroundColor: Color - - init( - tabTitle: String, - fallbackIconName: String?, - baseBackgroundHex: String?, - appearance: BonsplitConfiguration.Appearance, - iconOverride: String?, - backgroundHexOverride: String?, - onApply: @escaping (_ iconOverride: String?, _ backgroundHexOverride: String?) -> Void - ) { - self.tabTitle = tabTitle - self.fallbackIconName = fallbackIconName - self.baseBackgroundHex = baseBackgroundHex - self.appearance = appearance - self.onApply = onApply - _iconName = State(initialValue: iconOverride ?? "") - _usesCustomBackground = State(initialValue: backgroundHexOverride != nil) - _backgroundColor = State( - initialValue: TabAppearanceColor.color(hex: backgroundHexOverride) - ?? TabAppearanceColor.color(hex: baseBackgroundHex) - ?? Color(nsColor: .controlAccentColor) - ) - } - - var body: some View { - VStack(alignment: .leading, spacing: 16) { - Text(localized("tabAppearance.title", defaultValue: "Customize Tab")) - .font(.headline) - - preview - - GroupBox { - VStack(alignment: .leading, spacing: 10) { - HStack(spacing: 8) { - ForEach(Self.suggestedIcons, id: \.self) { icon in - suggestedIconButton(icon) - } - } - - TextField( - localized("tabAppearance.iconName", defaultValue: "SF Symbol name"), - text: $iconName - ) - .textFieldStyle(.roundedBorder) - .accessibilityIdentifier("bonsplit.tabAppearance.iconName") - - if !isIconValid { - Text(localized( - "tabAppearance.invalidIcon", - defaultValue: "This symbol is not available." - )) - .font(.caption) - .foregroundStyle(.red) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - } label: { - Label( - localized("tabAppearance.icon", defaultValue: "Icon"), - systemImage: "app.dashed" - ) - } - - GroupBox { - VStack(alignment: .leading, spacing: 10) { - Toggle( - localized( - "tabAppearance.useCustomBackground", - defaultValue: "Use custom background" - ), - isOn: $usesCustomBackground - ) - - ColorPicker( - localized("tabAppearance.backgroundColor", defaultValue: "Background color"), - selection: $backgroundColor, - supportsOpacity: true - ) - .disabled(!usesCustomBackground) - .accessibilityIdentifier("bonsplit.tabAppearance.backgroundColor") - } - .frame(maxWidth: .infinity, alignment: .leading) - } label: { - Label( - localized("tabAppearance.background", defaultValue: "Background"), - systemImage: "paintpalette" - ) - } - - HStack { - Button(localized("tabAppearance.reset", defaultValue: "Reset")) { - iconName = "" - usesCustomBackground = false - } - .disabled(iconOverride == nil && !usesCustomBackground) - - Spacer() - - Button(localized("tabAppearance.cancel", defaultValue: "Cancel")) { - dismiss() - } - .keyboardShortcut(.cancelAction) - - Button(localized("tabAppearance.save", defaultValue: "Save")) { - onApply(iconOverride, backgroundHexOverride) - dismiss() - } - .keyboardShortcut(.defaultAction) - .disabled(!canSave) - .accessibilityIdentifier("bonsplit.tabAppearance.save") - } - } - .padding(20) - .frame(width: 420) - .accessibilityIdentifier("bonsplit.tabAppearance.customizer") - } - - private var preview: some View { - HStack(spacing: 8) { - Image(systemName: previewIconName) - .frame(width: 16, height: 16) - Text(tabTitle) - .lineLimit(1) - } - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(previewForeground) - .padding(.horizontal, 12) - .frame(maxWidth: .infinity, minHeight: 34, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 7, style: .continuous) - .fill(previewBackground) - ) - .overlay( - RoundedRectangle(cornerRadius: 7, style: .continuous) - .stroke(.separator.opacity(0.45), lineWidth: 1) - ) - .accessibilityLabel(localized("tabAppearance.preview", defaultValue: "Preview")) - } - - private func suggestedIconButton(_ icon: String) -> some View { - Button { - iconName = icon - } label: { - Image(systemName: icon) - .frame(width: 30, height: 28) - .background( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(iconOverride == icon ? Color.accentColor.opacity(0.18) : .clear) - ) - .overlay( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .stroke(iconOverride == icon ? Color.accentColor : .clear, lineWidth: 1) - ) - } - .buttonStyle(.plain) - .accessibilityLabel(String( - format: localized("tabAppearance.useIcon", defaultValue: "Use %@ icon"), - icon - )) - .help(icon) - } - - private var trimmedIconName: String { - iconName.trimmingCharacters(in: .whitespacesAndNewlines) - } - - private var iconOverride: String? { - trimmedIconName.isEmpty ? nil : trimmedIconName - } - - private var backgroundHexOverride: String? { - guard usesCustomBackground else { return nil } - return TabAppearanceColor.hex(from: backgroundColor) - } - - private var isIconValid: Bool { - guard let iconOverride else { return true } - return NSImage(systemSymbolName: iconOverride, accessibilityDescription: nil) != nil - } - - private var canSave: Bool { - isIconValid && (!usesCustomBackground || backgroundHexOverride != nil) - } - - private var previewIconName: String { - guard let iconOverride else { return fallbackIconName ?? "doc.text" } - return isIconValid ? iconOverride : "questionmark.square.dashed" - } - - private var previewBackground: Color { - TabBarColors.tabBackground( - for: appearance, - backgroundHex: previewBackgroundHex, - isSelected: true, - isHovered: false - ) - } - - private var previewForeground: Color { - TabBarColors.tabText( - for: appearance, - backgroundHex: previewBackgroundHex, - isSelected: true - ) - } - - private var previewBackgroundHex: String? { - usesCustomBackground ? backgroundHexOverride : baseBackgroundHex - } - - private func localized(_ key: String, defaultValue: String) -> String { - Bundle.module.localizedString(forKey: key, value: defaultValue, table: nil) - } -} diff --git a/Sources/Bonsplit/Internal/Views/TabBarView.swift b/Sources/Bonsplit/Internal/Views/TabBarView.swift index 42bf77fe..2c83ed39 100644 --- a/Sources/Bonsplit/Internal/Views/TabBarView.swift +++ b/Sources/Bonsplit/Internal/Views/TabBarView.swift @@ -1290,11 +1290,32 @@ struct TabBarView: View { onMoveDestination: { destinationId in controller.requestTabMove(toDestination: destinationId, for: TabID(id: tab.id), inPane: pane.id) }, - onAppearanceUpdate: { iconOverride, backgroundHexOverride in + tabColorPaletteProvider: { + controller.tabColorPaletteProvider() + }, + onIconUpdate: { iconOverride in + controller.updateTab( + TabID(id: tab.id), + iconOverride: .some(iconOverride) + ) + }, + onTabColorUpdate: { colorHexOverride in + controller.updateTab( + TabID(id: tab.id), + colorHexOverride: .some(colorHexOverride) + ) + }, + onCustomTabColorUpdate: { colorHex in + let registeredColor: String? + if let registrationHandler = controller.tabColorCustomColorRegistrationHandler { + registeredColor = registrationHandler(colorHex) + } else { + registeredColor = colorHex + } + guard let registeredColor else { return } controller.updateTab( TabID(id: tab.id), - iconOverride: .some(iconOverride), - backgroundHexOverride: .some(backgroundHexOverride) + colorHexOverride: .some(registeredColor) ) } ) diff --git a/Sources/Bonsplit/Internal/Views/TabColorPickerView.swift b/Sources/Bonsplit/Internal/Views/TabColorPickerView.swift new file mode 100644 index 00000000..bd524209 --- /dev/null +++ b/Sources/Bonsplit/Internal/Views/TabColorPickerView.swift @@ -0,0 +1,82 @@ +import SwiftUI + +struct TabColorPickerView: View { + let tabTitle: String + let onApply: (_ colorHex: String) -> Void + + @Environment(\.dismiss) private var dismiss + @State private var color: Color + + init( + tabTitle: String, + initialColorHex: String?, + onApply: @escaping (_ colorHex: String) -> Void + ) { + self.tabTitle = tabTitle + self.onApply = onApply + _color = State( + initialValue: TabAppearanceColor.color(hex: initialColorHex) + ?? Color(nsColor: .controlAccentColor) + ) + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text(localized("tabColor.customTitle", defaultValue: "Custom Tab Color")) + .font(.headline) + + HStack(spacing: 10) { + Capsule(style: .continuous) + .fill(railColor) + .frame(width: 3, height: 26) + Text(tabTitle) + .lineLimit(1) + } + .padding(.horizontal, 12) + .frame(maxWidth: .infinity, minHeight: 38, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .fill(Color(nsColor: .controlBackgroundColor)) + ) + + ColorPicker( + localized("tabColor.color", defaultValue: "Color"), + selection: $color, + supportsOpacity: false + ) + .accessibilityIdentifier("bonsplit.tabColor.picker") + + HStack { + Spacer() + + Button(localized("tabColor.cancel", defaultValue: "Cancel")) { + dismiss() + } + .keyboardShortcut(.cancelAction) + + Button(localized("tabColor.apply", defaultValue: "Apply")) { + guard let hex = TabAppearanceColor.hex(from: color) else { return } + onApply(hex) + dismiss() + } + .keyboardShortcut(.defaultAction) + .accessibilityIdentifier("bonsplit.tabColor.apply") + } + } + .padding(20) + .frame(width: 360) + .accessibilityIdentifier("bonsplit.tabColor.customizer") + } + + private var railColor: Color { + guard let hex = TabAppearanceColor.hex(from: color), + let railColor = TabAppearanceColor.railNSColor(hex: hex) else { + return color + } + return Color(nsColor: railColor) + } + + private func localized(_ key: String, defaultValue: String) -> String { + Bundle.module.localizedString(forKey: key, value: defaultValue, table: nil) + } +} diff --git a/Sources/Bonsplit/Internal/Views/TabContextMenuPresenter.swift b/Sources/Bonsplit/Internal/Views/TabContextMenuPresenter.swift index fc66f929..5ff22f8d 100644 --- a/Sources/Bonsplit/Internal/Views/TabContextMenuPresenter.swift +++ b/Sources/Bonsplit/Internal/Views/TabContextMenuPresenter.swift @@ -4,6 +4,8 @@ import SwiftUI struct TabContextMenuSnapshot { let tabId: UUID let state: TabContextMenuState + let colorHexOverride: String? + let colorPaletteProvider: () -> [TabColorPaletteEntry] let moveDestinationsProvider: () -> [TabContextMoveDestination] let forkConversationAvailabilityProvider: () -> TabContextForkConversationAvailability let forkConversationAvailabilityRefreshHandler: @MainActor () async -> Void @@ -11,12 +13,18 @@ struct TabContextMenuSnapshot { init( tabId: UUID, state: TabContextMenuState, + colorHexOverride: String? = nil, + colorPaletteProvider: @escaping () -> [TabColorPaletteEntry] = { + TabColorPaletteEntry.defaultPalette + }, moveDestinationsProvider: @escaping () -> [TabContextMoveDestination], forkConversationAvailabilityProvider: @escaping () -> TabContextForkConversationAvailability, forkConversationAvailabilityRefreshHandler: @escaping @MainActor () async -> Void = {} ) { self.tabId = tabId self.state = state + self.colorHexOverride = colorHexOverride + self.colorPaletteProvider = colorPaletteProvider self.moveDestinationsProvider = moveDestinationsProvider self.forkConversationAvailabilityProvider = forkConversationAvailabilityProvider self.forkConversationAvailabilityRefreshHandler = forkConversationAvailabilityRefreshHandler @@ -26,7 +34,9 @@ struct TabContextMenuSnapshot { final class TabContextMenuActionTarget: NSObject { var onContextAction: ((TabContextAction) -> Void)? var onMoveDestination: ((String) -> Void)? - var onCustomizeAppearance: (() -> Void)? + var onCustomizeIcon: (() -> Void)? + var onTabColor: ((String?) -> Void)? + var onChooseCustomTabColor: (() -> Void)? @objc func performContextAction(_ sender: NSMenuItem) { guard let rawValue = sender.representedObject as? String, @@ -41,8 +51,17 @@ final class TabContextMenuActionTarget: NSObject { onMoveDestination?(destinationId) } - @objc func performCustomizeAppearance(_ sender: NSMenuItem) { - onCustomizeAppearance?() + @objc func performCustomizeIcon(_ sender: NSMenuItem) { + onCustomizeIcon?() + } + + @objc func performTabColor(_ sender: NSMenuItem) { + guard let value = sender.representedObject as? String else { return } + onTabColor?(value.isEmpty ? nil : value) + } + + @objc func performChooseCustomTabColor(_ sender: NSMenuItem) { + onChooseCustomTabColor?() } } @@ -105,7 +124,9 @@ struct TabContextMenuPresenter: NSViewRepresentable { let snapshot: TabContextMenuSnapshot let onContextAction: (TabContextAction) -> Void let onMoveDestination: (String) -> Void - let onCustomizeAppearance: () -> Void + let onCustomizeIcon: () -> Void + let onTabColor: (String?) -> Void + let onChooseCustomTabColor: () -> Void @MainActor final class Coordinator { @@ -134,7 +155,9 @@ struct TabContextMenuPresenter: NSViewRepresentable { let coordinator = Coordinator(snapshot: snapshot) coordinator.actionTarget.onContextAction = onContextAction coordinator.actionTarget.onMoveDestination = onMoveDestination - coordinator.actionTarget.onCustomizeAppearance = onCustomizeAppearance + coordinator.actionTarget.onCustomizeIcon = onCustomizeIcon + coordinator.actionTarget.onTabColor = onTabColor + coordinator.actionTarget.onChooseCustomTabColor = onChooseCustomTabColor return coordinator } @@ -166,6 +189,8 @@ struct TabContextMenuPresenter: NSViewRepresentable { context.coordinator.snapshot = snapshot context.coordinator.actionTarget.onContextAction = onContextAction context.coordinator.actionTarget.onMoveDestination = onMoveDestination - context.coordinator.actionTarget.onCustomizeAppearance = onCustomizeAppearance + context.coordinator.actionTarget.onCustomizeIcon = onCustomizeIcon + context.coordinator.actionTarget.onTabColor = onTabColor + context.coordinator.actionTarget.onChooseCustomTabColor = onChooseCustomTabColor } } diff --git a/Sources/Bonsplit/Internal/Views/TabDragPreview.swift b/Sources/Bonsplit/Internal/Views/TabDragPreview.swift index f2e619c5..972498fc 100644 --- a/Sources/Bonsplit/Internal/Views/TabDragPreview.swift +++ b/Sources/Bonsplit/Internal/Views/TabDragPreview.swift @@ -12,7 +12,7 @@ struct TabDragPreview: View { .font(.system(size: TabBarMetrics.iconSize)) .foregroundStyle(TabBarColors.tabText( for: appearance, - backgroundHex: tab.resolvedBackgroundHex, + backgroundHex: tab.backgroundHex, isSelected: true )) } @@ -22,7 +22,7 @@ struct TabDragPreview: View { .lineLimit(1) .foregroundStyle(TabBarColors.tabText( for: appearance, - backgroundHex: tab.resolvedBackgroundHex, + backgroundHex: tab.backgroundHex, isSelected: true )) } @@ -32,10 +32,21 @@ struct TabDragPreview: View { RoundedRectangle(cornerRadius: TabBarMetrics.tabCornerRadius, style: .continuous) .fill(TabBarColors.tabBackground( for: appearance, - backgroundHex: tab.resolvedBackgroundHex, + backgroundHex: tab.backgroundHex, isSelected: true, isHovered: false )) + .overlay(alignment: .leading) { + if let railColor = tab.colorHexOverride.flatMap({ + TabAppearanceColor.railNSColor(hex: $0) + }) { + Capsule(style: .continuous) + .fill(Color(nsColor: railColor).opacity(0.95)) + .frame(width: 3) + .padding(.leading, 4) + .padding(.vertical, 5) + } + } .shadow(color: .black.opacity(0.2), radius: 4, y: 2) ) .opacity(0.9) diff --git a/Sources/Bonsplit/Internal/Views/TabIconCatalog.swift b/Sources/Bonsplit/Internal/Views/TabIconCatalog.swift new file mode 100644 index 00000000..24074ee5 --- /dev/null +++ b/Sources/Bonsplit/Internal/Views/TabIconCatalog.swift @@ -0,0 +1,163 @@ +import AppKit + +@MainActor +enum TabIconCatalog { + static let names = [ + "terminal", + "terminal.fill", + "apple.terminal", + "apple.terminal.fill", + "chevron.left.forwardslash.chevron.right", + "curlybraces", + "command", + "keyboard", + "keyboard.fill", + "globe", + "globe.americas", + "network", + "server.rack", + "externaldrive", + "externaldrive.fill", + "internaldrive", + "internaldrive.fill", + "desktopcomputer", + "laptopcomputer", + "display", + "cpu", + "memorychip", + "folder", + "folder.fill", + "folder.badge.gearshape", + "archivebox", + "archivebox.fill", + "doc", + "doc.fill", + "doc.text", + "doc.text.fill", + "doc.richtext", + "doc.on.doc", + "doc.on.doc.fill", + "book", + "book.fill", + "books.vertical", + "books.vertical.fill", + "bookmark", + "bookmark.fill", + "tray", + "tray.fill", + "shippingbox", + "shippingbox.fill", + "cube", + "cube.fill", + "square.stack.3d.up", + "square.stack.3d.up.fill", + "hammer", + "hammer.fill", + "wrench.and.screwdriver", + "wrench.and.screwdriver.fill", + "gearshape", + "gearshape.fill", + "slider.horizontal.3", + "switch.2", + "bolt", + "bolt.fill", + "sparkles", + "wand.and.stars", + "paintbrush", + "paintbrush.fill", + "pencil", + "pencil.circle", + "highlighter", + "scissors", + "link", + "paperclip", + "magnifyingglass", + "checkmark", + "checkmark.circle", + "checkmark.circle.fill", + "xmark.circle", + "xmark.circle.fill", + "exclamationmark.triangle", + "exclamationmark.triangle.fill", + "questionmark.circle", + "questionmark.circle.fill", + "info.circle", + "info.circle.fill", + "star", + "star.fill", + "heart", + "heart.fill", + "flag", + "flag.fill", + "tag", + "tag.fill", + "bell", + "bell.fill", + "bubble.left", + "bubble.left.fill", + "bubble.left.and.bubble.right", + "bubble.left.and.bubble.right.fill", + "message", + "message.fill", + "envelope", + "envelope.fill", + "paperplane", + "paperplane.fill", + "person", + "person.fill", + "person.2", + "person.2.fill", + "person.3", + "person.3.fill", + "brain.head.profile", + "lightbulb", + "lightbulb.fill", + "chart.bar", + "chart.bar.fill", + "chart.line.uptrend.xyaxis", + "waveform", + "play", + "play.fill", + "music.note", + "headphones", + "photo", + "photo.fill", + "camera", + "camera.fill", + "video", + "video.fill", + "map", + "map.fill", + "location", + "location.fill", + "calendar", + "clock", + "clock.fill", + "timer", + "lock", + "lock.fill", + "key", + "key.fill", + "shield", + "shield.fill", + "ant", + "ant.fill", + "ladybug", + "ladybug.fill", + "leaf", + "leaf.fill", + "flame", + "flame.fill", + "cloud", + "cloud.fill", + "cloud.bolt", + "wifi", + "antenna.radiowaves.left.and.right", + "arrow.triangle.branch", + "point.3.connected.trianglepath.dotted", + ] + + static let availableNames = names.filter { + NSImage(systemSymbolName: $0, accessibilityDescription: nil) != nil + } +} diff --git a/Sources/Bonsplit/Internal/Views/TabIconCustomizerView.swift b/Sources/Bonsplit/Internal/Views/TabIconCustomizerView.swift new file mode 100644 index 00000000..d9666f93 --- /dev/null +++ b/Sources/Bonsplit/Internal/Views/TabIconCustomizerView.swift @@ -0,0 +1,204 @@ +import AppKit +import SwiftUI + +struct TabIconCustomizerView: View { + private static let columns = Array( + repeating: GridItem(.flexible(minimum: 32), spacing: 6), + count: 10 + ) + + let tabTitle: String + let fallbackIconName: String? + let baseBackgroundHex: String? + let appearance: BonsplitConfiguration.Appearance + let onApply: (_ iconOverride: String?) -> Void + + @Environment(\.dismiss) private var dismiss + @State private var iconName: String + @State private var searchText = "" + + init( + tabTitle: String, + fallbackIconName: String?, + baseBackgroundHex: String?, + appearance: BonsplitConfiguration.Appearance, + iconOverride: String?, + onApply: @escaping (_ iconOverride: String?) -> Void + ) { + self.tabTitle = tabTitle + self.fallbackIconName = fallbackIconName + self.baseBackgroundHex = baseBackgroundHex + self.appearance = appearance + self.onApply = onApply + _iconName = State(initialValue: iconOverride ?? "") + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Text(localized("tabIcon.title", defaultValue: "Tab Icon")) + .font(.headline) + + preview + + TextField( + localized("tabIcon.search", defaultValue: "Search icons"), + text: $searchText + ) + .textFieldStyle(.roundedBorder) + .accessibilityIdentifier("bonsplit.tabIcon.search") + + ScrollView { + if filteredIcons.isEmpty { + Text(localized("tabIcon.noMatches", defaultValue: "No matching icons")) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, minHeight: 180) + } else { + LazyVGrid(columns: Self.columns, spacing: 6) { + ForEach(filteredIcons, id: \.self) { icon in + iconButton(icon) + } + } + .padding(2) + } + } + .frame(height: 250) + + VStack(alignment: .leading, spacing: 6) { + Text(localized("tabIcon.symbolNameLabel", defaultValue: "SF Symbol")) + .font(.caption) + .foregroundStyle(.secondary) + + TextField( + localized("tabIcon.symbolName", defaultValue: "SF Symbol name"), + text: $iconName + ) + .textFieldStyle(.roundedBorder) + .accessibilityIdentifier("bonsplit.tabIcon.symbolName") + + if !isIconValid { + Text(localized( + "tabIcon.invalid", + defaultValue: "This symbol is not available." + )) + .font(.caption) + .foregroundStyle(.red) + } + } + + HStack { + Button(localized("tabIcon.useDefault", defaultValue: "Use Default")) { + iconName = "" + } + .disabled(iconOverride == nil) + + Spacer() + + Button(localized("tabIcon.cancel", defaultValue: "Cancel")) { + dismiss() + } + .keyboardShortcut(.cancelAction) + + Button(localized("tabIcon.save", defaultValue: "Save")) { + onApply(iconOverride) + dismiss() + } + .keyboardShortcut(.defaultAction) + .disabled(!isIconValid) + .accessibilityIdentifier("bonsplit.tabIcon.save") + } + } + .padding(20) + .frame(width: 540) + .accessibilityIdentifier("bonsplit.tabIcon.customizer") + } + + private var preview: some View { + HStack(spacing: 8) { + Image(systemName: previewIconName) + .frame(width: 16, height: 16) + Text(tabTitle) + .lineLimit(1) + } + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(TabBarColors.tabText( + for: appearance, + backgroundHex: baseBackgroundHex, + isSelected: true + )) + .padding(.horizontal, 12) + .frame(maxWidth: .infinity, minHeight: 34, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .fill(TabBarColors.tabBackground( + for: appearance, + backgroundHex: baseBackgroundHex, + isSelected: true, + isHovered: false + )) + ) + .overlay( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .stroke(.separator.opacity(0.45), lineWidth: 1) + ) + .accessibilityLabel(localized("tabIcon.preview", defaultValue: "Preview")) + } + + private func iconButton(_ icon: String) -> some View { + Button { + iconName = icon + } label: { + Image(systemName: icon) + .frame(maxWidth: .infinity, minHeight: 32) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(iconOverride == icon ? Color.accentColor.opacity(0.18) : .clear) + ) + .overlay( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(iconOverride == icon ? Color.accentColor : .clear, lineWidth: 1) + ) + } + .buttonStyle(.plain) + .accessibilityLabel(String( + format: localized("tabIcon.useIcon", defaultValue: "Use %@ icon"), + icon + )) + .help(icon) + } + + private var filteredIcons: [String] { + let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !query.isEmpty else { return TabIconCatalog.availableNames } + return TabIconCatalog.availableNames.filter { + $0.localizedCaseInsensitiveContains(query) + } + } + + private var trimmedIconName: String { + iconName.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var iconOverride: String? { + trimmedIconName.isEmpty ? nil : trimmedIconName + } + + private var isIconValid: Bool { + guard let iconOverride else { return true } + return NSImage(systemSymbolName: iconOverride, accessibilityDescription: nil) != nil + } + + private var previewIconName: String { + if let iconOverride, isIconValid { + return iconOverride + } + if let fallbackIconName, + NSImage(systemSymbolName: fallbackIconName, accessibilityDescription: nil) != nil { + return fallbackIconName + } + return "doc.text" + } + + private func localized(_ key: String, defaultValue: String) -> String { + Bundle.module.localizedString(forKey: key, value: defaultValue, table: nil) + } +} diff --git a/Sources/Bonsplit/Internal/Views/TabItemView.swift b/Sources/Bonsplit/Internal/Views/TabItemView.swift index 0e0294b9..2013b947 100644 --- a/Sources/Bonsplit/Internal/Views/TabItemView.swift +++ b/Sources/Bonsplit/Internal/Views/TabItemView.swift @@ -292,7 +292,10 @@ struct TabItemView: View { let onZoomToggle: () -> Void let onContextAction: (TabContextAction) -> Void let onMoveDestination: (String) -> Void - let onAppearanceUpdate: (_ iconOverride: String?, _ backgroundHexOverride: String?) -> Void + let tabColorPaletteProvider: () -> [TabColorPaletteEntry] + let onIconUpdate: (_ iconOverride: String?) -> Void + let onTabColorUpdate: (_ colorHexOverride: String?) -> Void + let onCustomTabColorUpdate: (_ colorHex: String) -> Void @State private var isHovered = false @State private var isCloseHovered = false @@ -304,7 +307,8 @@ struct TabItemView: View { @State private var lastLoadingStoppedAt: Date? @State private var renderedFaviconData: Data? @State private var renderedFaviconImage: NSImage? - @State private var isAppearanceCustomizerPresented = false + @State private var isIconCustomizerPresented = false + @State private var isCustomColorPickerPresented = false #if DEBUG @State private var debugLastIconFrame: CGRect? @State private var debugLastTitleFrame: CGRect? @@ -353,27 +357,39 @@ struct TabItemView: View { snapshot: TabContextMenuSnapshot( tabId: tab.id, state: contextMenuState, + colorHexOverride: tab.colorHexOverride, + colorPaletteProvider: tabColorPaletteProvider, moveDestinationsProvider: moveDestinationsProvider, forkConversationAvailabilityProvider: forkConversationAvailabilityProvider, forkConversationAvailabilityRefreshHandler: forkConversationAvailabilityRefreshHandler ), onContextAction: onContextAction, onMoveDestination: onMoveDestination, - onCustomizeAppearance: { - isAppearanceCustomizerPresented = true + onCustomizeIcon: { + isIconCustomizerPresented = true + }, + onTabColor: onTabColorUpdate, + onChooseCustomTabColor: { + isCustomColorPickerPresented = true } ) } } - .sheet(isPresented: $isAppearanceCustomizerPresented) { - TabAppearanceCustomizerView( + .sheet(isPresented: $isIconCustomizerPresented) { + TabIconCustomizerView( tabTitle: tab.title, fallbackIconName: tab.icon, baseBackgroundHex: tab.backgroundHex, appearance: appearance, iconOverride: tab.iconOverride, - backgroundHexOverride: tab.backgroundHexOverride, - onApply: onAppearanceUpdate + onApply: onIconUpdate + ) + } + .sheet(isPresented: $isCustomColorPickerPresented) { + TabColorPickerView( + tabTitle: tab.title, + initialColorHex: tab.colorHexOverride, + onApply: onCustomTabColorUpdate ) } .onTapGesture { @@ -417,7 +433,7 @@ struct TabItemView: View { private var emphasizedTabText: Color { TabBarColors.tabText( for: appearance, - backgroundHex: tab.resolvedBackgroundHex, + backgroundHex: tab.backgroundHex, isSelected: isSelected, isHovered: isHovered, isSecondary: false @@ -427,7 +443,7 @@ struct TabItemView: View { private var subduedTabText: Color { TabBarColors.tabText( for: appearance, - backgroundHex: tab.resolvedBackgroundHex, + backgroundHex: tab.backgroundHex, isSelected: isSelected, isHovered: isHovered, isSecondary: true @@ -441,7 +457,7 @@ struct TabItemView: View { private var currentTabTextNSColor: NSColor { TabBarColors.nsColorTabText( for: appearance, - backgroundHex: tab.resolvedBackgroundHex, + backgroundHex: tab.backgroundHex, isSelected: isSelected, isHovered: isHovered ) @@ -450,7 +466,7 @@ struct TabItemView: View { private var customHoverControlColors: TabBarColors.TabControlColorPair? { TabBarColors.customTabControlColors( for: appearance, - backgroundHex: tab.resolvedBackgroundHex, + backgroundHex: tab.backgroundHex, isSelected: isSelected, isHovered: isHovered ) @@ -459,7 +475,7 @@ struct TabItemView: View { private var customPinnedBadgeColors: TabBarColors.TabControlColorPair? { TabBarColors.customTabControlColors( for: appearance, - backgroundHex: tab.resolvedBackgroundHex, + backgroundHex: tab.backgroundHex, isSelected: isSelected, isHovered: isHovered, prominent: true @@ -698,7 +714,11 @@ struct TabItemView: View { } } .saturation(saturation) - .accessibilityLabel("Exit zoom") + .accessibilityLabel(Bundle.module.localizedString( + forKey: "tabContext.exitZoom", + value: "Exit Zoom", + table: nil + )) .tabBarButtonAnimationsDisabled() } } @@ -1066,7 +1086,7 @@ struct TabItemView: View { Rectangle() .fill(TabBarColors.tabBackground( for: appearance, - backgroundHex: tab.resolvedBackgroundHex, + backgroundHex: tab.backgroundHex, isSelected: isSelected, isHovered: TabItemStyling.shouldShowHoverBackground( isHovered: isHovered, @@ -1082,6 +1102,19 @@ struct TabItemView: View { .frame(width: 1) .padding(.bottom, max(0, trailingSeparatorBottomInset)) } + + if let railColor = tab.colorHexOverride.flatMap({ + TabAppearanceColor.railNSColor(hex: $0) + }) { + HStack { + Capsule(style: .continuous) + .fill(Color(nsColor: railColor).opacity(0.95)) + .frame(width: 3) + .padding(.leading, 4) + .padding(.vertical, 5) + Spacer(minLength: 0) + } + } } } @@ -1444,13 +1477,14 @@ enum TabContextMenuBuilder { to: menu ) - let customizeAppearanceItem = NSMenuItem( - title: localized("tabContext.customizeAppearance", defaultValue: "Customize Tab…"), - action: #selector(TabContextMenuActionTarget.performCustomizeAppearance(_:)), + let customizeIconItem = NSMenuItem( + title: localized("tabContext.tabIcon", defaultValue: "Tab Icon…"), + action: #selector(TabContextMenuActionTarget.performCustomizeIcon(_:)), keyEquivalent: "" ) - customizeAppearanceItem.target = target - menu.addItem(customizeAppearanceItem) + customizeIconItem.target = target + menu.addItem(customizeIconItem) + menu.addItem(tabColorSubmenuItem(snapshot: snapshot, target: target)) if state.hasCustomTitle { addAction( @@ -1677,6 +1711,74 @@ enum TabContextMenuBuilder { } } + private static func tabColorSubmenuItem( + snapshot: TabContextMenuSnapshot, + target: TabContextMenuActionTarget + ) -> NSMenuItem { + let parent = NSMenuItem( + title: localized("tabContext.tabColor", defaultValue: "Tab Color"), + action: nil, + keyEquivalent: "" + ) + let submenu = NSMenu() + submenu.autoenablesItems = false + + if snapshot.colorHexOverride != nil { + let clearItem = NSMenuItem( + title: localized("tabContext.clearColor", defaultValue: "Clear Color"), + action: #selector(TabContextMenuActionTarget.performTabColor(_:)), + keyEquivalent: "" + ) + clearItem.target = target + clearItem.representedObject = "" + clearItem.image = NSImage(systemSymbolName: "xmark.circle", accessibilityDescription: nil) + submenu.addItem(clearItem) + } + + let customItem = NSMenuItem( + title: localized("tabContext.chooseCustomColor", defaultValue: "Choose Custom Color…"), + action: #selector(TabContextMenuActionTarget.performChooseCustomTabColor(_:)), + keyEquivalent: "" + ) + customItem.target = target + customItem.image = NSImage(systemSymbolName: "paintpalette", accessibilityDescription: nil) + submenu.addItem(customItem) + + let palette = snapshot.colorPaletteProvider().filter { + TabAppearanceColor.nsColor(hex: $0.hex) != nil + } + if !palette.isEmpty { + submenu.addItem(.separator()) + } + + for entry in palette { + let colorItem = NSMenuItem( + title: entry.name, + action: #selector(TabContextMenuActionTarget.performTabColor(_:)), + keyEquivalent: "" + ) + colorItem.target = target + colorItem.representedObject = entry.hex + colorItem.image = colorSwatchImage(hex: entry.hex) + if snapshot.colorHexOverride?.caseInsensitiveCompare(entry.hex) == .orderedSame { + colorItem.state = .on + } + submenu.addItem(colorItem) + } + + parent.submenu = submenu + return parent + } + + private static func colorSwatchImage(hex: String, diameter: CGFloat = 12) -> NSImage? { + guard let color = TabAppearanceColor.railNSColor(hex: hex) else { return nil } + return NSImage(size: NSSize(width: diameter, height: diameter), flipped: false) { rect in + color.setFill() + NSBezierPath(ovalIn: rect.insetBy(dx: 0.5, dy: 0.5)).fill() + return true + } + } + private static func moveSubmenuItem( snapshot: TabContextMenuSnapshot, target: TabContextMenuActionTarget diff --git a/Sources/Bonsplit/Public/BonsplitController.swift b/Sources/Bonsplit/Public/BonsplitController.swift index 5c30eefb..32d3da1e 100644 --- a/Sources/Bonsplit/Public/BonsplitController.swift +++ b/Sources/Bonsplit/Public/BonsplitController.swift @@ -97,6 +97,19 @@ public final class BonsplitController { /// the provider) to hide it. @ObservationIgnored public var tabContextDisconnectRemoteAvailabilityProvider: ((TabID, PaneID) -> Bool)? + /// Supplies the named colors shown in the tab color context menu. + /// + /// Hosts can return their live workspace palette so both menus stay in sync. + @ObservationIgnored public var tabColorPaletteProvider: @MainActor () -> [TabColorPaletteEntry] = { + TabColorPaletteEntry.defaultPalette + } + + /// Optionally registers a custom color selected from the tab color picker. + /// + /// Return the normalized color to apply, or `nil` to reject it. When omitted, + /// Bonsplit applies the selected color directly without registering it. + @ObservationIgnored public var tabColorCustomColorRegistrationHandler: (@MainActor (String) -> String?)? + /// Called when the user explicitly requests to close a tab from the tab strip UI. /// Internal host-driven closes should not use this hook. @ObservationIgnored public var onTabCloseRequest: ((_ tabId: TabID, _ paneId: PaneID, _ source: TabCloseRequestSource) -> Void)? @@ -164,7 +177,7 @@ public final class BonsplitController { /// - iconAsset: Optional asset-catalog image name for the tab icon. Resolved in the host app bundle. /// - backgroundHex: Optional per-tab background color as `#RRGGBB` or `#RRGGBBAA`. /// - iconOverride: Optional user-selected SF Symbol that takes precedence over host-provided icons. - /// - backgroundHexOverride: Optional user-selected background that takes precedence over `backgroundHex`. + /// - colorHexOverride: Optional user-selected color rendered as a leading accent rail. /// - kind: Consumer-defined tab kind identifier (e.g. "terminal", "browser") /// - hasCustomTitle: Whether the tab title came from a custom user override /// - isDirty: Whether the tab shows a dirty indicator @@ -183,7 +196,7 @@ public final class BonsplitController { iconAsset: String? = nil, backgroundHex: String? = nil, iconOverride: String? = nil, - backgroundHexOverride: String? = nil, + colorHexOverride: String? = nil, kind: String? = nil, isDirty: Bool = false, showsNotificationBadge: Bool = false, @@ -204,7 +217,7 @@ public final class BonsplitController { iconAsset: iconAsset, backgroundHex: backgroundHex, iconOverride: iconOverride, - backgroundHexOverride: backgroundHexOverride, + colorHexOverride: colorHexOverride, kind: kind, isDirty: isDirty, showsNotificationBadge: showsNotificationBadge, @@ -248,7 +261,7 @@ public final class BonsplitController { iconAsset: iconAsset, backgroundHex: backgroundHex, iconOverride: iconOverride, - backgroundHexOverride: backgroundHexOverride, + colorHexOverride: colorHexOverride, kind: kind, isDirty: isDirty, showsNotificationBadge: showsNotificationBadge, @@ -303,7 +316,7 @@ public final class BonsplitController { /// - iconAsset: New asset-catalog icon name (pass nil to keep current, pass .some(nil) to remove) /// - backgroundHex: New per-tab background color (pass nil to keep current, pass .some(nil) to remove) /// - iconOverride: New user-selected SF Symbol (pass nil to keep current, pass .some(nil) to restore the host icon) - /// - backgroundHexOverride: New user-selected background (pass nil to keep current, pass .some(nil) to restore `backgroundHex`) + /// - colorHexOverride: New user-selected accent color (pass nil to keep current, pass .some(nil) to clear) /// - kind: New tab kind (pass nil to keep current, pass .some(nil) to clear) /// - hasCustomTitle: New custom-title state (pass nil to keep current) /// - isDirty: New dirty state (pass nil to keep current) @@ -320,7 +333,7 @@ public final class BonsplitController { iconAsset: String?? = nil, backgroundHex: String?? = nil, iconOverride: String?? = nil, - backgroundHexOverride: String?? = nil, + colorHexOverride: String?? = nil, kind: String?? = nil, hasCustomTitle: Bool? = nil, isDirty: Bool? = nil, @@ -340,7 +353,7 @@ public final class BonsplitController { iconAsset.map { currentTab.iconAsset != $0 } ?? false || backgroundHex.map { currentTab.backgroundHex != $0 } ?? false || iconOverride.map { currentTab.iconOverride != $0 } ?? false || - backgroundHexOverride.map { currentTab.backgroundHexOverride != $0 } ?? false || + colorHexOverride.map { currentTab.colorHexOverride != $0 } ?? false || kind.map { currentTab.kind != $0 } ?? false || hasCustomTitle.map { currentTab.hasCustomTitle != $0 } ?? false || isDirty.map { currentTab.isDirty != $0 } ?? false || @@ -370,8 +383,8 @@ public final class BonsplitController { if let iconOverride = iconOverride { pane.tabs[tabIndex].iconOverride = iconOverride } - if let backgroundHexOverride = backgroundHexOverride { - pane.tabs[tabIndex].backgroundHexOverride = backgroundHexOverride + if let colorHexOverride = colorHexOverride { + pane.tabs[tabIndex].colorHexOverride = colorHexOverride } if let kind = kind { pane.tabs[tabIndex].kind = kind @@ -565,7 +578,7 @@ public final class BonsplitController { iconAsset: tab.iconAsset, backgroundHex: tab.backgroundHex, iconOverride: tab.iconOverride, - backgroundHexOverride: tab.backgroundHexOverride, + colorHexOverride: tab.colorHexOverride, kind: tab.kind, isDirty: tab.isDirty, showsNotificationBadge: tab.showsNotificationBadge, @@ -637,7 +650,7 @@ public final class BonsplitController { iconAsset: tab.iconAsset, backgroundHex: tab.backgroundHex, iconOverride: tab.iconOverride, - backgroundHexOverride: tab.backgroundHexOverride, + colorHexOverride: tab.colorHexOverride, kind: tab.kind, isDirty: tab.isDirty, showsNotificationBadge: tab.showsNotificationBadge, diff --git a/Sources/Bonsplit/Public/Types/Tab.swift b/Sources/Bonsplit/Public/Types/Tab.swift index f42e65b5..8afae0b8 100644 --- a/Sources/Bonsplit/Public/Types/Tab.swift +++ b/Sources/Bonsplit/Public/Types/Tab.swift @@ -14,8 +14,8 @@ public struct Tab: Identifiable, Hashable, Sendable { public let backgroundHex: String? /// Optional SF Symbol selected by the user. When present, this takes precedence over all host-provided icons. public let iconOverride: String? - /// Optional background selected by the user. When present, this takes precedence over `backgroundHex`. - public let backgroundHexOverride: String? + /// Optional color selected by the user and rendered as a leading accent rail. + public let colorHexOverride: String? /// Consumer-defined tab kind identifier (for example, "terminal" or "browser"). public let kind: String? public let isDirty: Bool @@ -42,7 +42,7 @@ public struct Tab: Identifiable, Hashable, Sendable { iconAsset: String? = nil, backgroundHex: String? = nil, iconOverride: String? = nil, - backgroundHexOverride: String? = nil, + colorHexOverride: String? = nil, kind: String? = nil, isDirty: Bool = false, showsNotificationBadge: Bool = false, @@ -60,7 +60,7 @@ public struct Tab: Identifiable, Hashable, Sendable { self.iconAsset = iconAsset self.backgroundHex = backgroundHex self.iconOverride = iconOverride - self.backgroundHexOverride = backgroundHexOverride + self.colorHexOverride = colorHexOverride self.kind = kind self.isDirty = isDirty self.showsNotificationBadge = showsNotificationBadge @@ -80,7 +80,7 @@ public struct Tab: Identifiable, Hashable, Sendable { self.iconAsset = tabItem.iconAsset self.backgroundHex = tabItem.backgroundHex self.iconOverride = tabItem.iconOverride - self.backgroundHexOverride = tabItem.backgroundHexOverride + self.colorHexOverride = tabItem.colorHexOverride self.kind = tabItem.kind self.isDirty = tabItem.isDirty self.showsNotificationBadge = tabItem.showsNotificationBadge diff --git a/Sources/Bonsplit/Public/Types/TabColorPaletteEntry.swift b/Sources/Bonsplit/Public/Types/TabColorPaletteEntry.swift new file mode 100644 index 00000000..ec738f9e --- /dev/null +++ b/Sources/Bonsplit/Public/Types/TabColorPaletteEntry.swift @@ -0,0 +1,44 @@ +import Foundation + +/// A named color shown in the tab color context-menu palette. +public struct TabColorPaletteEntry: Identifiable, Hashable, Sendable { + /// The user-facing color name. + public let name: String + + /// The color encoded as `#RRGGBB` or `#RRGGBBAA`. + public let hex: String + + /// A stable identifier derived from the color name. + public var id: String { name } + + /// Creates a named tab color palette entry. + /// + /// - Parameters: + /// - name: The user-facing color name. + /// - hex: The color encoded as `#RRGGBB` or `#RRGGBBAA`. + public init(name: String, hex: String) { + self.name = name + self.hex = hex + } +} + +extension TabColorPaletteEntry { + static let defaultPalette: [Self] = [ + .init(name: "Red", hex: "#C0392B"), + .init(name: "Crimson", hex: "#922B21"), + .init(name: "Orange", hex: "#A04000"), + .init(name: "Amber", hex: "#7D6608"), + .init(name: "Olive", hex: "#4A5C18"), + .init(name: "Green", hex: "#196F3D"), + .init(name: "Teal", hex: "#006B6B"), + .init(name: "Aqua", hex: "#0E6B8C"), + .init(name: "Blue", hex: "#1565C0"), + .init(name: "Navy", hex: "#1A5276"), + .init(name: "Indigo", hex: "#283593"), + .init(name: "Purple", hex: "#6A1B9A"), + .init(name: "Magenta", hex: "#AD1457"), + .init(name: "Rose", hex: "#880E4F"), + .init(name: "Brown", hex: "#7B3F00"), + .init(name: "Charcoal", hex: "#3E4B5E"), + ] +} diff --git a/Sources/Bonsplit/Resources/en.lproj/Localizable.strings b/Sources/Bonsplit/Resources/en.lproj/Localizable.strings index b64959f8..677f45a8 100644 --- a/Sources/Bonsplit/Resources/en.lproj/Localizable.strings +++ b/Sources/Bonsplit/Resources/en.lproj/Localizable.strings @@ -4,7 +4,10 @@ "command.moveTabToLeftPane.title" = "Move to Left Pane"; "command.moveTabToRightPane.title" = "Move to Right Pane"; "tabContext.renameTab" = "Rename Tab…"; -"tabContext.customizeAppearance" = "Customize Tab…"; +"tabContext.tabIcon" = "Tab Icon…"; +"tabContext.tabColor" = "Tab Color"; +"tabContext.clearColor" = "Clear Color"; +"tabContext.chooseCustomColor" = "Choose Custom Color…"; "tabContext.removeCustomTabName" = "Remove Custom Tab Name"; "tabContext.closeTabsToLeft" = "Close Tabs to Left"; "tabContext.closeTabsToRight" = "Close Tabs to Right"; @@ -42,15 +45,18 @@ "tabContext.forkConversation.newWorkspace" = "New Workspace"; "tabContext.disconnectRemote" = "Disconnect SSH"; "tabContext.remoteConnectedAccessibility" = "Connected over SSH"; -"tabAppearance.title" = "Customize Tab"; -"tabAppearance.preview" = "Preview"; -"tabAppearance.icon" = "Icon"; -"tabAppearance.iconName" = "SF Symbol name"; -"tabAppearance.invalidIcon" = "This symbol is not available."; -"tabAppearance.useIcon" = "Use %@ icon"; -"tabAppearance.background" = "Background"; -"tabAppearance.useCustomBackground" = "Use custom background"; -"tabAppearance.backgroundColor" = "Background color"; -"tabAppearance.reset" = "Reset"; -"tabAppearance.cancel" = "Cancel"; -"tabAppearance.save" = "Save"; +"tabIcon.title" = "Tab Icon"; +"tabIcon.preview" = "Preview"; +"tabIcon.search" = "Search icons"; +"tabIcon.noMatches" = "No matching icons"; +"tabIcon.symbolNameLabel" = "SF Symbol"; +"tabIcon.symbolName" = "SF Symbol name"; +"tabIcon.invalid" = "This symbol is not available."; +"tabIcon.useIcon" = "Use %@ icon"; +"tabIcon.useDefault" = "Use Default"; +"tabIcon.cancel" = "Cancel"; +"tabIcon.save" = "Save"; +"tabColor.customTitle" = "Custom Tab Color"; +"tabColor.color" = "Color"; +"tabColor.cancel" = "Cancel"; +"tabColor.apply" = "Apply"; diff --git a/Sources/Bonsplit/Resources/ja.lproj/Localizable.strings b/Sources/Bonsplit/Resources/ja.lproj/Localizable.strings index 2dcb6f25..11dffb8c 100644 --- a/Sources/Bonsplit/Resources/ja.lproj/Localizable.strings +++ b/Sources/Bonsplit/Resources/ja.lproj/Localizable.strings @@ -4,7 +4,10 @@ "command.moveTabToLeftPane.title" = "左のペインへ移動"; "command.moveTabToRightPane.title" = "右のペインへ移動"; "tabContext.renameTab" = "タブ名を変更…"; -"tabContext.customizeAppearance" = "タブをカスタマイズ…"; +"tabContext.tabIcon" = "タブアイコン…"; +"tabContext.tabColor" = "タブの色"; +"tabContext.clearColor" = "色をクリア"; +"tabContext.chooseCustomColor" = "カスタムカラーを選択…"; "tabContext.removeCustomTabName" = "カスタムタブ名を削除"; "tabContext.closeTabsToLeft" = "左側のタブを閉じる"; "tabContext.closeTabsToRight" = "右側のタブを閉じる"; @@ -42,15 +45,18 @@ "tabContext.forkConversation.newWorkspace" = "新規ワークスペース"; "tabContext.disconnectRemote" = "SSH接続を解除"; "tabContext.remoteConnectedAccessibility" = "SSH接続中"; -"tabAppearance.title" = "タブをカスタマイズ"; -"tabAppearance.preview" = "プレビュー"; -"tabAppearance.icon" = "アイコン"; -"tabAppearance.iconName" = "SF Symbols名"; -"tabAppearance.invalidIcon" = "このシンボルは使用できません。"; -"tabAppearance.useIcon" = "%@アイコンを使用"; -"tabAppearance.background" = "背景"; -"tabAppearance.useCustomBackground" = "カスタム背景を使用"; -"tabAppearance.backgroundColor" = "背景色"; -"tabAppearance.reset" = "リセット"; -"tabAppearance.cancel" = "キャンセル"; -"tabAppearance.save" = "保存"; +"tabIcon.title" = "タブアイコン"; +"tabIcon.preview" = "プレビュー"; +"tabIcon.search" = "アイコンを検索"; +"tabIcon.noMatches" = "一致するアイコンがありません"; +"tabIcon.symbolNameLabel" = "SF Symbols"; +"tabIcon.symbolName" = "SF Symbols名"; +"tabIcon.invalid" = "このシンボルは使用できません。"; +"tabIcon.useIcon" = "%@アイコンを使用"; +"tabIcon.useDefault" = "デフォルトを使用"; +"tabIcon.cancel" = "キャンセル"; +"tabIcon.save" = "保存"; +"tabColor.customTitle" = "カスタムタブカラー"; +"tabColor.color" = "色"; +"tabColor.cancel" = "キャンセル"; +"tabColor.apply" = "適用"; diff --git a/Tests/BonsplitTests/BonsplitTests.swift b/Tests/BonsplitTests/BonsplitTests.swift index dd113248..124d819b 100644 --- a/Tests/BonsplitTests/BonsplitTests.swift +++ b/Tests/BonsplitTests/BonsplitTests.swift @@ -265,7 +265,17 @@ final class BonsplitTests: XCTestCase { XCTAssertEqual(tab.title, "Legacy") XCTAssertNil(tab.backgroundHex) XCTAssertNil(tab.iconOverride) - XCTAssertNil(tab.backgroundHexOverride) + XCTAssertNil(tab.colorHexOverride) + } + + func testLegacyBackgroundColorOverrideDecodesAsTabColor() throws { + let data = Data( + ##"{"id":"11111111-1111-1111-1111-111111111111","title":"Legacy","backgroundHexOverride":"#AABBCC"}"##.utf8 + ) + + let tab = try JSONDecoder().decode(TabItem.self, from: data) + + XCTAssertEqual(tab.colorHexOverride, "#AABBCC") } @MainActor @@ -282,12 +292,12 @@ final class BonsplitTests: XCTestCase { controller.updateTab( tabId, iconOverride: .some("star.fill"), - backgroundHexOverride: .some("#AABBCC80") + colorHexOverride: .some("#AABBCC80") ) var tab = controller.tab(tabId) XCTAssertEqual(tab?.iconOverride, "star.fill") - XCTAssertEqual(tab?.backgroundHexOverride, "#AABBCC80") + XCTAssertEqual(tab?.colorHexOverride, "#AABBCC80") XCTAssertEqual(tab?.icon, "globe") XCTAssertEqual(tab?.iconImageData, favicon) XCTAssertEqual(tab?.backgroundHex, "#112233") @@ -295,34 +305,36 @@ final class BonsplitTests: XCTestCase { controller.updateTab( tabId, iconOverride: .some(nil), - backgroundHexOverride: .some(nil) + colorHexOverride: .some(nil) ) tab = controller.tab(tabId) XCTAssertNil(tab?.iconOverride) - XCTAssertNil(tab?.backgroundHexOverride) + XCTAssertNil(tab?.colorHexOverride) XCTAssertEqual(tab?.icon, "globe") XCTAssertEqual(tab?.iconImageData, favicon) XCTAssertEqual(tab?.backgroundHex, "#112233") } - func testTabItemResolvesUserAppearanceAheadOfHostAppearance() { + func testTabItemKeepsUserAccentSeparateFromHostBackground() { var tab = TabItem( title: "Customized", icon: "globe", backgroundHex: "#112233", iconOverride: "star.fill", - backgroundHexOverride: "#AABBCC" + colorHexOverride: "#AABBCC" ) XCTAssertEqual(tab.resolvedIconName, "star.fill") - XCTAssertEqual(tab.resolvedBackgroundHex, "#AABBCC") + XCTAssertEqual(tab.backgroundHex, "#112233") + XCTAssertEqual(tab.colorHexOverride, "#AABBCC") tab.iconOverride = nil - tab.backgroundHexOverride = nil + tab.colorHexOverride = nil XCTAssertEqual(tab.resolvedIconName, "globe") - XCTAssertEqual(tab.resolvedBackgroundHex, "#112233") + XCTAssertEqual(tab.backgroundHex, "#112233") + XCTAssertNil(tab.colorHexOverride) } @MainActor @@ -335,7 +347,7 @@ final class BonsplitTests: XCTestCase { iconAsset: "AgentIcons/Claude", backgroundHex: "#112233", iconOverride: "star.fill", - backgroundHexOverride: "#445566", + colorHexOverride: "#445566", kind: "terminal", isDirty: true, showsNotificationBadge: true, @@ -359,7 +371,7 @@ final class BonsplitTests: XCTestCase { iconAsset: .some("AgentIcons/Claude"), backgroundHex: .some("#112233"), iconOverride: .some("star.fill"), - backgroundHexOverride: .some("#445566"), + colorHexOverride: .some("#445566"), kind: .some("terminal"), hasCustomTitle: true, isDirty: true, @@ -409,7 +421,7 @@ final class BonsplitTests: XCTestCase { } @MainActor - func testSplitPaneWithTabPreservesIconAndBackground() { + func testSplitPaneWithTabPreservesIconBackgroundAndAccentColor() { let controller = BonsplitController() _ = controller.createTab(title: "Base") let styled = Bonsplit.Tab( @@ -417,7 +429,7 @@ final class BonsplitTests: XCTestCase { icon: "paintbrush.fill", backgroundHex: "#336699", iconOverride: "star.fill", - backgroundHexOverride: "#663399" + colorHexOverride: "#663399" ) let newPane = controller.splitPane(orientation: .horizontal, withTab: styled) @@ -426,7 +438,7 @@ final class BonsplitTests: XCTestCase { XCTAssertEqual(controller.tab(styled.id)?.icon, "paintbrush.fill") XCTAssertEqual(controller.tab(styled.id)?.backgroundHex, "#336699") XCTAssertEqual(controller.tab(styled.id)?.iconOverride, "star.fill") - XCTAssertEqual(controller.tab(styled.id)?.backgroundHexOverride, "#663399") + XCTAssertEqual(controller.tab(styled.id)?.colorHexOverride, "#663399") } @MainActor @@ -1385,6 +1397,13 @@ final class BonsplitTests: XCTestCase { XCTAssertNil(TabAppearanceColor.nsColor(hex: "#not-a-color")) } + @MainActor + func testTabIconCatalogOffersBroadAvailableSelection() { + XCTAssertGreaterThanOrEqual(TabIconCatalog.availableNames.count, 40) + XCTAssertTrue(TabIconCatalog.availableNames.contains("terminal.fill")) + XCTAssertTrue(TabIconCatalog.availableNames.contains("star.fill")) + } + func testPaneBackgroundHexOverrideCanDifferFromChromeBackground() { let appearance = BonsplitConfiguration.Appearance( chromeColors: .init( @@ -2086,10 +2105,14 @@ final class BonsplitTests: XCTestCase { let target = TabContextMenuActionTarget() var selectedAction: TabContextAction? var selectedDestinationId: String? - var customizeAppearanceRequestCount = 0 + var customizeIconRequestCount = 0 + var selectedTabColors: [String?] = [] + var customColorRequestCount = 0 target.onContextAction = { selectedAction = $0 } target.onMoveDestination = { selectedDestinationId = $0 } - target.onCustomizeAppearance = { customizeAppearanceRequestCount += 1 } + target.onCustomizeIcon = { customizeIconRequestCount += 1 } + target.onTabColor = { selectedTabColors.append($0) } + target.onChooseCustomTabColor = { customColorRequestCount += 1 } let state = TabContextMenuState( isPinned: false, isUnread: false, @@ -2112,6 +2135,13 @@ final class BonsplitTests: XCTestCase { let snapshot = TabContextMenuSnapshot( tabId: UUID(), state: state, + colorHexOverride: "#1565C0", + colorPaletteProvider: { + [ + TabColorPaletteEntry(name: "Blue", hex: "#1565C0"), + TabColorPaletteEntry(name: "Purple", hex: "#6A1B9A"), + ] + }, moveDestinationsProvider: { moveDestinationRequestCount += 1 return [ @@ -2123,10 +2153,16 @@ final class BonsplitTests: XCTestCase { let menu = TabContextMenuBuilder.makeMenu(snapshot: snapshot, target: target) let moveItem = menu.items.first { $0.title == "Move Tab" } - let customizeAppearanceItem = menu.items.first { $0.title == "Customize Tab…" } + let tabIconItem = menu.items.first { $0.title == "Tab Icon…" } + let tabColorItem = menu.items.first { $0.title == "Tab Color" } XCTAssertEqual(moveDestinationRequestCount, 1) - XCTAssertNotNil(customizeAppearanceItem) + XCTAssertNotNil(tabIconItem) + XCTAssertEqual( + tabColorItem?.submenu?.items.map(\.title), + ["Clear Color", "Choose Custom Color…", "", "Blue", "Purple"] + ) + XCTAssertEqual(tabColorItem?.submenu?.items[3].state, .on) XCTAssertNotNil(moveItem) XCTAssertTrue(moveItem?.isEnabled ?? false) XCTAssertEqual(moveItem?.submenu?.items.map(\.title), ["Move Tab to New Workspace", "Workspace A"]) @@ -2140,8 +2176,21 @@ final class BonsplitTests: XCTestCase { target.performMoveDestination(workspaceItem) XCTAssertEqual(selectedDestinationId, "workspace:abc") - target.performCustomizeAppearance(try XCTUnwrap(customizeAppearanceItem)) - XCTAssertEqual(customizeAppearanceRequestCount, 1) + target.performCustomizeIcon(try XCTUnwrap(tabIconItem)) + XCTAssertEqual(customizeIconRequestCount, 1) + + let clearColorItem = try XCTUnwrap(tabColorItem?.submenu?.items.first) + target.performTabColor(clearColorItem) + XCTAssertEqual(selectedTabColors.count, 1) + XCTAssertNil(selectedTabColors[0]) + + let blueItem = try XCTUnwrap(tabColorItem?.submenu?.items[3]) + target.performTabColor(blueItem) + XCTAssertEqual(selectedTabColors.last!, "#1565C0") + + let customColorItem = try XCTUnwrap(tabColorItem?.submenu?.items[1]) + target.performChooseCustomTabColor(customColorItem) + XCTAssertEqual(customColorRequestCount, 1) } @MainActor From 47768136d705c84f6ef3dce49953eeb260de0105 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:03:38 -0700 Subject: [PATCH 06/10] test: require flush square tab color rail --- Tests/BonsplitTests/BonsplitTests.swift | 51 +++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/Tests/BonsplitTests/BonsplitTests.swift b/Tests/BonsplitTests/BonsplitTests.swift index 124d819b..37186781 100644 --- a/Tests/BonsplitTests/BonsplitTests.swift +++ b/Tests/BonsplitTests/BonsplitTests.swift @@ -1397,6 +1397,21 @@ final class BonsplitTests: XCTestCase { XCTAssertNil(TabAppearanceColor.nsColor(hex: "#not-a-color")) } + @MainActor + func testTabColorRailPaintsTheFullSquareLeftBorder() throws { + let samples = try XCTUnwrap(renderedTabColorRailSamples()) + XCTAssertEqual(samples.count, 3) + + for sample in samples { + let color = try XCTUnwrap(sample.usingColorSpace(.sRGB)) + XCTAssertGreaterThan( + color.redComponent - max(color.greenComponent, color.blueComponent), + 0.4 + ) + XCTAssertGreaterThan(color.alphaComponent, 0.8) + } + } + @MainActor func testTabIconCatalogOffersBroadAvailableSelection() { XCTAssertGreaterThanOrEqual(TabIconCatalog.availableNames.count, 40) @@ -5671,6 +5686,42 @@ final class BonsplitTests: XCTestCase { } } + @MainActor + private func renderedTabColorRailSamples() -> [NSColor]? { + let appearance = BonsplitConfiguration.Appearance( + chromeColors: .init( + backgroundHex: "#000000", + tabBarBackgroundHex: "#000000", + borderHex: "#00000000" + ) + ) + return renderedTabBarValue( + isFocused: false, + appearance: appearance, + size: NSSize(width: 160, height: TabBarMetrics.barHeight), + configurePane: { pane in + let styled = TabItem( + title: "", + icon: nil, + colorHexOverride: "#FF0000" + ) + let selected = TabItem(title: "", icon: nil) + pane.tabs = [styled, selected] + pane.selectedTabId = selected.id + } + ) { hostingView in + let x: CGFloat = 0.25 + let samplePoints = [ + NSPoint(x: x, y: 0.25), + NSPoint(x: x, y: TabBarMetrics.barHeight / 2), + NSPoint(x: x, y: TabBarMetrics.barHeight - 0.25) + ] + return samplePoints.compactMap { + renderedColorInViewCoordinates(in: hostingView, at: $0) + } + } + } + @MainActor private func renderedColorInViewCoordinates(in view: NSView, at point: NSPoint) -> NSColor? { let integralBounds = view.bounds.integral From 0323cee59e788428e22c2325c2ebc5fd9164b4da Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:06:41 -0700 Subject: [PATCH 07/10] Render tab colors as square left borders --- Sources/Bonsplit/Internal/Styling/TabBarMetrics.swift | 1 + Sources/Bonsplit/Internal/Views/TabColorPickerView.swift | 8 +++++--- Sources/Bonsplit/Internal/Views/TabDragPreview.swift | 6 ++---- Sources/Bonsplit/Internal/Views/TabItemView.swift | 6 ++---- 4 files changed, 10 insertions(+), 11 deletions(-) diff --git a/Sources/Bonsplit/Internal/Styling/TabBarMetrics.swift b/Sources/Bonsplit/Internal/Styling/TabBarMetrics.swift index 36edee53..3d1ea4c8 100644 --- a/Sources/Bonsplit/Internal/Styling/TabBarMetrics.swift +++ b/Sources/Bonsplit/Internal/Styling/TabBarMetrics.swift @@ -18,6 +18,7 @@ enum TabBarMetrics { static let activeIndicatorHeight: CGFloat = 1.5 static let activeIndicatorTrailingInset: CGFloat = 1 static let selectedTabLeftSeparatorBottomInset: CGFloat = 1 + static let tabColorRailWidth: CGFloat = 3 // MARK: - Tab Content diff --git a/Sources/Bonsplit/Internal/Views/TabColorPickerView.swift b/Sources/Bonsplit/Internal/Views/TabColorPickerView.swift index bd524209..c4c86f02 100644 --- a/Sources/Bonsplit/Internal/Views/TabColorPickerView.swift +++ b/Sources/Bonsplit/Internal/Views/TabColorPickerView.swift @@ -26,9 +26,6 @@ struct TabColorPickerView: View { .font(.headline) HStack(spacing: 10) { - Capsule(style: .continuous) - .fill(railColor) - .frame(width: 3, height: 26) Text(tabTitle) .lineLimit(1) } @@ -38,6 +35,11 @@ struct TabColorPickerView: View { RoundedRectangle(cornerRadius: 7, style: .continuous) .fill(Color(nsColor: .controlBackgroundColor)) ) + .overlay(alignment: .leading) { + Rectangle() + .fill(railColor) + .frame(width: TabBarMetrics.tabColorRailWidth) + } ColorPicker( localized("tabColor.color", defaultValue: "Color"), diff --git a/Sources/Bonsplit/Internal/Views/TabDragPreview.swift b/Sources/Bonsplit/Internal/Views/TabDragPreview.swift index 972498fc..fd27fef5 100644 --- a/Sources/Bonsplit/Internal/Views/TabDragPreview.swift +++ b/Sources/Bonsplit/Internal/Views/TabDragPreview.swift @@ -40,11 +40,9 @@ struct TabDragPreview: View { if let railColor = tab.colorHexOverride.flatMap({ TabAppearanceColor.railNSColor(hex: $0) }) { - Capsule(style: .continuous) + Rectangle() .fill(Color(nsColor: railColor).opacity(0.95)) - .frame(width: 3) - .padding(.leading, 4) - .padding(.vertical, 5) + .frame(width: TabBarMetrics.tabColorRailWidth) } } .shadow(color: .black.opacity(0.2), radius: 4, y: 2) diff --git a/Sources/Bonsplit/Internal/Views/TabItemView.swift b/Sources/Bonsplit/Internal/Views/TabItemView.swift index 2013b947..6b22c66b 100644 --- a/Sources/Bonsplit/Internal/Views/TabItemView.swift +++ b/Sources/Bonsplit/Internal/Views/TabItemView.swift @@ -1107,11 +1107,9 @@ struct TabItemView: View { TabAppearanceColor.railNSColor(hex: $0) }) { HStack { - Capsule(style: .continuous) + Rectangle() .fill(Color(nsColor: railColor).opacity(0.95)) - .frame(width: 3) - .padding(.leading, 4) - .padding(.vertical, 5) + .frame(width: TabBarMetrics.tabColorRailWidth) Spacer(minLength: 0) } } From 01be8bb089c2e5575b1a92ab0e31656b2fe89e7b Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:05:34 -0700 Subject: [PATCH 08/10] test: match tab rail to selection weight --- Tests/BonsplitTests/BonsplitTests.swift | 47 +++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/Tests/BonsplitTests/BonsplitTests.swift b/Tests/BonsplitTests/BonsplitTests.swift index 37186781..87bc3726 100644 --- a/Tests/BonsplitTests/BonsplitTests.swift +++ b/Tests/BonsplitTests/BonsplitTests.swift @@ -1412,6 +1412,17 @@ final class BonsplitTests: XCTestCase { } } + @MainActor + func testTabColorRailMatchesSelectedIndicatorWeight() throws { + let width = try XCTUnwrap(renderedTabColorRailWidth()) + + XCTAssertEqual( + width, + TabBarMetrics.activeIndicatorHeight, + accuracy: 0.01 + ) + } + @MainActor func testTabIconCatalogOffersBroadAvailableSelection() { XCTAssertGreaterThanOrEqual(TabIconCatalog.availableNames.count, 40) @@ -5722,6 +5733,42 @@ final class BonsplitTests: XCTestCase { } } + @MainActor + private func renderedTabColorRailWidth() -> CGFloat? { + let appearance = BonsplitConfiguration.Appearance( + chromeColors: .init( + backgroundHex: "#000000", + tabBarBackgroundHex: "#000000", + borderHex: "#00000000" + ) + ) + return renderedTabBarValue( + isFocused: false, + appearance: appearance, + size: NSSize(width: 160, height: TabBarMetrics.barHeight), + configurePane: { pane in + let styled = TabItem( + title: "", + icon: nil, + colorHexOverride: "#FF0000" + ) + let selected = TabItem(title: "", icon: nil) + pane.tabs = [styled, selected] + pane.selectedTabId = selected.id + } + ) { hostingView in + highSaturationWidth( + in: hostingView, + sampleRect: NSRect( + x: 0, + y: 4, + width: TabBarMetrics.tabMinWidth, + height: TabBarMetrics.barHeight - 8 + ) + ) + } + } + @MainActor private func renderedColorInViewCoordinates(in view: NSView, at point: NSPoint) -> NSColor? { let integralBounds = view.bounds.integral From 5be33c5bbbd0af4d7561a74a21c17d1ebcd87513 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:05:51 -0700 Subject: [PATCH 09/10] Match tab color rail to active indicator --- Sources/Bonsplit/Internal/Styling/TabBarMetrics.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Bonsplit/Internal/Styling/TabBarMetrics.swift b/Sources/Bonsplit/Internal/Styling/TabBarMetrics.swift index 3d1ea4c8..6ce760dc 100644 --- a/Sources/Bonsplit/Internal/Styling/TabBarMetrics.swift +++ b/Sources/Bonsplit/Internal/Styling/TabBarMetrics.swift @@ -18,7 +18,7 @@ enum TabBarMetrics { static let activeIndicatorHeight: CGFloat = 1.5 static let activeIndicatorTrailingInset: CGFloat = 1 static let selectedTabLeftSeparatorBottomInset: CGFloat = 1 - static let tabColorRailWidth: CGFloat = 3 + static let tabColorRailWidth: CGFloat = activeIndicatorHeight // MARK: - Tab Content From 2058d6710baae5eea57b46442a75b987a879df5f Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:32:12 -0700 Subject: [PATCH 10/10] Make tab rail render test scale aware --- Tests/BonsplitTests/BonsplitTests.swift | 69 ++++++++++++++++++++++--- 1 file changed, 62 insertions(+), 7 deletions(-) diff --git a/Tests/BonsplitTests/BonsplitTests.swift b/Tests/BonsplitTests/BonsplitTests.swift index 87bc3726..9bdb4dc9 100644 --- a/Tests/BonsplitTests/BonsplitTests.swift +++ b/Tests/BonsplitTests/BonsplitTests.swift @@ -1414,11 +1414,11 @@ final class BonsplitTests: XCTestCase { @MainActor func testTabColorRailMatchesSelectedIndicatorWeight() throws { - let width = try XCTUnwrap(renderedTabColorRailWidth()) + let weights = try XCTUnwrap(renderedTabColorRailAndSelectedIndicatorWeights()) XCTAssertEqual( - width, - TabBarMetrics.activeIndicatorHeight, + weights.railWidth, + weights.indicatorHeight, accuracy: 0.01 ) } @@ -5734,7 +5734,10 @@ final class BonsplitTests: XCTestCase { } @MainActor - private func renderedTabColorRailWidth() -> CGFloat? { + private func renderedTabColorRailAndSelectedIndicatorWeights() -> ( + railWidth: CGFloat, + indicatorHeight: CGFloat + )? { let appearance = BonsplitConfiguration.Appearance( chromeColors: .init( backgroundHex: "#000000", @@ -5743,7 +5746,7 @@ final class BonsplitTests: XCTestCase { ) ) return renderedTabBarValue( - isFocused: false, + isFocused: true, appearance: appearance, size: NSSize(width: 160, height: TabBarMetrics.barHeight), configurePane: { pane in @@ -5757,7 +5760,7 @@ final class BonsplitTests: XCTestCase { pane.selectedTabId = selected.id } ) { hostingView in - highSaturationWidth( + guard let railWidth = highSaturationWidth( in: hostingView, sampleRect: NSRect( x: 0, @@ -5765,7 +5768,19 @@ final class BonsplitTests: XCTestCase { width: TabBarMetrics.tabMinWidth, height: TabBarMetrics.barHeight - 8 ) - ) + ), + let indicatorHeight = highSaturationHeight( + in: hostingView, + sampleRect: NSRect( + x: TabBarMetrics.tabMinWidth + 8, + y: 0, + width: 8, + height: TabBarMetrics.barHeight + ) + ) else { + return nil + } + return (railWidth: railWidth, indicatorHeight: indicatorHeight) } } @@ -6071,6 +6086,46 @@ final class BonsplitTests: XCTestCase { return CGFloat(activeColumnCount) / scaleX } + @MainActor + private func highSaturationHeight(in view: NSView, sampleRect: NSRect) -> CGFloat? { + let integralBounds = view.bounds.integral + guard let bitmap = view.bitmapImageRepForCachingDisplay(in: integralBounds) else { return nil } + bitmap.size = integralBounds.size + view.cacheDisplay(in: integralBounds, to: bitmap) + + let scaleX = CGFloat(bitmap.pixelsWide) / max(1, integralBounds.width) + let scaleY = CGFloat(bitmap.pixelsHigh) / max(1, integralBounds.height) + let minX = max(0, Int(floor(sampleRect.minX * scaleX))) + let maxX = min(bitmap.pixelsWide, Int(ceil(sampleRect.maxX * scaleX))) + let minY = max(0, Int(floor(sampleRect.minY * scaleY))) + let maxY = min(bitmap.pixelsHigh, Int(ceil(sampleRect.maxY * scaleY))) + + var activeRowCount = 0 + for y in minY.. 0.05 else { continue } + let alpha = min(max(rgb.alphaComponent, 0), 1) + let red = rgb.redComponent * alpha + let green = rgb.greenComponent * alpha + let blue = rgb.blueComponent * alpha + let high = max(red, green, blue) + guard high > 0.01 else { continue } + let low = min(red, green, blue) + if (high - low) / high > 0.4 { + hasIndicatorPixel = true + break + } + } + if hasIndicatorPixel { + activeRowCount += 1 + } + } + return CGFloat(activeRowCount) / scaleY + } + @MainActor private func highSaturationRange(in view: NSView, sampleRect: NSRect) -> ClosedRange? { let integralBounds = view.bounds.integral