diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 87fb732a..a270f941 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -935,6 +935,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } #if DEBUG + private let debugColorWorkspaceTitlePrefix = "Debug Color - " + @objc func openDebugScrollbackTab(_ sender: Any?) { guard let tabManager else { return } let tab = tabManager.addTab() @@ -958,6 +960,32 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent sendTextWhenReady(payload, to: tab) } + @objc func openDebugColorComparisonWorkspaces(_ sender: Any?) { + guard let tabManager else { return } + + let palette = WorkspaceTabColorSettings.palette() + guard !palette.isEmpty else { return } + + var existingByTitle: [String: Workspace] = [:] + for tab in tabManager.tabs { + guard let title = tab.customTitle, + title.hasPrefix(debugColorWorkspaceTitlePrefix) else { continue } + existingByTitle[title] = tab + } + + for entry in palette { + let title = "\(debugColorWorkspaceTitlePrefix)\(entry.name)" + let targetTab: Workspace + if let existing = existingByTitle[title] { + targetTab = existing + } else { + targetTab = tabManager.addTab() + } + tabManager.setCustomTitle(tabId: targetTab.id, title: title) + tabManager.setTabColor(tabId: targetTab.id, color: entry.hex) + } + } + private func sendTextWhenReady(_ text: String, to tab: Tab, attempt: Int = 0) { let maxAttempts = 60 if let terminalPanel = tab.focusedTerminalPanel, terminalPanel.surface.surface != nil { diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 470c5d81..82de33b1 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -5,6 +5,29 @@ import ObjectiveC import UniformTypeIdentifiers import WebKit +private extension Color { + init?(hex: String) { + let hex = hex.trimmingCharacters(in: .init(charactersIn: "#")) + guard hex.count == 6, let value = UInt64(hex, radix: 16) else { return nil } + self.init( + red: Double((value >> 16) & 0xFF) / 255.0, + green: Double((value >> 8) & 0xFF) / 255.0, + blue: Double( value & 0xFF) / 255.0 + ) + } +} + +private func coloredCircleImage(color: NSColor) -> NSImage { + let size = NSSize(width: 14, height: 14) + let image = NSImage(size: size, flipped: false) { rect in + color.setFill() + NSBezierPath(ovalIn: rect.insetBy(dx: 1, dy: 1)).fill() + return true + } + image.isTemplate = false + return image +} + struct ShortcutHintPillBackground: View { var emphasis: Double = 1.0 @@ -2439,6 +2462,7 @@ private struct SidebarEmptyArea: View { private struct TabItemView: View { @EnvironmentObject var tabManager: TabManager @EnvironmentObject var notificationStore: TerminalNotificationStore + @Environment(\.colorScheme) private var colorScheme @ObservedObject var tab: Tab let index: Int let rowSpacing: CGFloat @@ -2461,6 +2485,8 @@ private struct TabItemView: View { @AppStorage("sidebarShowLog") private var sidebarShowLog = true @AppStorage("sidebarShowProgress") private var sidebarShowProgress = true @AppStorage("sidebarShowStatusPills") private var sidebarShowStatusPills = true + @AppStorage(SidebarActiveTabIndicatorSettings.styleKey) + private var activeTabIndicatorStyleRaw = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue var isActive: Bool { tabManager.selectedTabId == tab.id @@ -2474,6 +2500,65 @@ private struct TabItemView: View { draggedTabId == tab.id } + private var activeTabIndicatorStyle: SidebarActiveTabIndicatorStyle { + SidebarActiveTabIndicatorSettings.resolvedStyle(rawValue: activeTabIndicatorStyleRaw) + } + + private var titleFontWeight: Font.Weight { + .semibold + } + + private var showsLeadingRail: Bool { + explicitRailColor != nil + } + + private var activeBorderLineWidth: CGFloat { + switch activeTabIndicatorStyle { + case .leftRail: + return 0 + case .solidFill: + return isActive ? 1.5 : 0 + } + } + + private var activeBorderColor: Color { + guard isActive else { return .clear } + switch activeTabIndicatorStyle { + case .leftRail: + return .clear + case .solidFill: + return Color.primary.opacity(0.5) + } + } + + private var usesInvertedActiveForeground: Bool { + isActive + } + + private var activePrimaryTextColor: Color { + usesInvertedActiveForeground ? .white : .primary + } + + private func activeSecondaryColor(_ opacity: Double = 0.75) -> Color { + usesInvertedActiveForeground ? .white.opacity(opacity) : .secondary + } + + private var activeUnreadBadgeFillColor: Color { + usesInvertedActiveForeground ? Color.white.opacity(0.25) : Color.accentColor + } + + private var activeProgressTrackColor: Color { + usesInvertedActiveForeground ? Color.white.opacity(0.15) : Color.secondary.opacity(0.2) + } + + private var activeProgressFillColor: Color { + usesInvertedActiveForeground ? Color.white.opacity(0.8) : Color.accentColor + } + + private var shortcutHintEmphasis: Double { + usesInvertedActiveForeground ? 1.0 : 0.9 + } + private var workspaceShortcutDigit: Int? { WorkspaceShortcutMapper.commandDigitForWorkspace(at: index, workspaceCount: tabManager.tabs.count) } @@ -2510,7 +2595,7 @@ private struct TabItemView: View { if unreadCount > 0 { ZStack { Circle() - .fill(isActive ? Color.white.opacity(0.25) : Color.accentColor) + .fill(activeUnreadBadgeFillColor) Text("\(unreadCount)") .font(.system(size: 9, weight: .semibold)) .foregroundColor(.white) @@ -2521,12 +2606,12 @@ private struct TabItemView: View { if tab.isPinned { Image(systemName: "pin.fill") .font(.system(size: 9, weight: .semibold)) - .foregroundColor(isActive ? .white.opacity(0.8) : .secondary) + .foregroundColor(activeSecondaryColor(0.8)) } Text(tab.title) - .font(.system(size: 12.5, weight: .semibold)) - .foregroundColor(isActive ? .white : .primary) + .font(.system(size: 12.5, weight: titleFontWeight)) + .foregroundColor(activePrimaryTextColor) .lineLimit(1) .truncationMode(.tail) @@ -2541,7 +2626,7 @@ private struct TabItemView: View { }) { Image(systemName: "xmark") .font(.system(size: 9, weight: .medium)) - .foregroundColor(isActive ? .white.opacity(0.7) : .secondary) + .foregroundColor(activeSecondaryColor(0.7)) } .buttonStyle(.plain) .help(KeyboardShortcutSettings.Action.closeWorkspace.tooltip("Close Workspace")) @@ -2555,10 +2640,10 @@ private struct TabItemView: View { .fixedSize(horizontal: true, vertical: false) .font(.system(size: 10, weight: .semibold, design: .rounded)) .monospacedDigit() - .foregroundColor(isActive ? .white : .primary) + .foregroundColor(activePrimaryTextColor) .padding(.horizontal, 6) .padding(.vertical, 2) - .background(ShortcutHintPillBackground(emphasis: isActive ? 1.0 : 0.9)) + .background(ShortcutHintPillBackground(emphasis: shortcutHintEmphasis)) .offset( x: ShortcutHintDebugSettings.clamped(sidebarShortcutHintXOffset), y: ShortcutHintDebugSettings.clamped(sidebarShortcutHintYOffset) @@ -2573,7 +2658,7 @@ private struct TabItemView: View { if let subtitle = latestNotificationText { Text(subtitle) .font(.system(size: 10)) - .foregroundColor(isActive ? .white.opacity(0.8) : .secondary) + .foregroundColor(activeSecondaryColor(0.8)) .lineLimit(2) .truncationMode(.tail) .multilineTextAlignment(.leading) @@ -2585,7 +2670,7 @@ private struct TabItemView: View { if lhs.timestamp != rhs.timestamp { return lhs.timestamp > rhs.timestamp } return lhs.key < rhs.key }), - isActive: isActive, + isActive: usesInvertedActiveForeground, onFocus: { updateSelection() } ) .transition(.opacity.combined(with: .move(edge: .top))) @@ -2596,10 +2681,10 @@ private struct TabItemView: View { HStack(spacing: 4) { Image(systemName: logLevelIcon(latestLog.level)) .font(.system(size: 8)) - .foregroundColor(logLevelColor(latestLog.level, isActive: isActive)) + .foregroundColor(logLevelColor(latestLog.level, isActive: usesInvertedActiveForeground)) Text(latestLog.message) .font(.system(size: 10)) - .foregroundColor(isActive ? .white.opacity(0.8) : .secondary) + .foregroundColor(activeSecondaryColor(0.8)) .lineLimit(1) .truncationMode(.tail) } @@ -2612,9 +2697,9 @@ private struct TabItemView: View { GeometryReader { geo in ZStack(alignment: .leading) { Capsule() - .fill(isActive ? Color.white.opacity(0.15) : Color.secondary.opacity(0.2)) + .fill(activeProgressTrackColor) Capsule() - .fill(isActive ? Color.white.opacity(0.8) : Color.accentColor) + .fill(activeProgressFillColor) .frame(width: max(0, geo.size.width * CGFloat(progress.value))) } } @@ -2623,7 +2708,7 @@ private struct TabItemView: View { if let label = progress.label { Text(label) .font(.system(size: 9)) - .foregroundColor(isActive ? .white.opacity(0.6) : .secondary) + .foregroundColor(activeSecondaryColor(0.6)) .lineLimit(1) } } @@ -2637,7 +2722,7 @@ private struct TabItemView: View { if sidebarShowGitBranchIcon, sidebarShowGitBranch, verticalRowsContainBranch { Image(systemName: "arrow.triangle.branch") .font(.system(size: 9)) - .foregroundColor(isActive ? .white.opacity(0.6) : .secondary) + .foregroundColor(activeSecondaryColor(0.6)) } VStack(alignment: .leading, spacing: 1) { ForEach(Array(verticalBranchDirectoryLines.enumerated()), id: \.offset) { _, line in @@ -2645,20 +2730,20 @@ private struct TabItemView: View { if let branch = line.branch { Text(branch) .font(.system(size: 10, design: .monospaced)) - .foregroundColor(isActive ? .white.opacity(0.75) : .secondary) + .foregroundColor(activeSecondaryColor(0.75)) .lineLimit(1) .truncationMode(.tail) } if line.branch != nil, line.directory != nil { Image(systemName: "circle.fill") .font(.system(size: 3)) - .foregroundColor(isActive ? .white.opacity(0.6) : .secondary) + .foregroundColor(activeSecondaryColor(0.6)) .padding(.horizontal, 1) } if let directory = line.directory { Text(directory) .font(.system(size: 10, design: .monospaced)) - .foregroundColor(isActive ? .white.opacity(0.75) : .secondary) + .foregroundColor(activeSecondaryColor(0.75)) .lineLimit(1) .truncationMode(.tail) } @@ -2672,11 +2757,11 @@ private struct TabItemView: View { if sidebarShowGitBranch && gitBranchSummaryText != nil && sidebarShowGitBranchIcon { Image(systemName: "arrow.triangle.branch") .font(.system(size: 9)) - .foregroundColor(isActive ? .white.opacity(0.6) : .secondary) + .foregroundColor(activeSecondaryColor(0.6)) } Text(dirRow) .font(.system(size: 10, design: .monospaced)) - .foregroundColor(isActive ? .white.opacity(0.75) : .secondary) + .foregroundColor(activeSecondaryColor(0.75)) .lineLimit(1) .truncationMode(.tail) } @@ -2686,7 +2771,7 @@ private struct TabItemView: View { if sidebarShowPorts, !tab.listeningPorts.isEmpty { Text(tab.listeningPorts.map { ":\($0)" }.joined(separator: ", ")) .font(.system(size: 10, design: .monospaced)) - .foregroundColor(isActive ? .white.opacity(0.75) : .secondary) + .foregroundColor(activeSecondaryColor(0.75)) .lineLimit(1) .truncationMode(.tail) } @@ -2698,6 +2783,20 @@ private struct TabItemView: View { .background( RoundedRectangle(cornerRadius: 6) .fill(backgroundColor) + .overlay { + RoundedRectangle(cornerRadius: 6) + .strokeBorder(activeBorderColor, lineWidth: activeBorderLineWidth) + } + .overlay(alignment: .leading) { + if showsLeadingRail { + Capsule(style: .continuous) + .fill(railColor) + .frame(width: 3) + .padding(.leading, 4) + .padding(.vertical, 5) + .offset(x: -1) + } + } ) .padding(.horizontal, 6) .background { @@ -2765,6 +2864,7 @@ private struct TabItemView: View { } .contextMenu { let targetIds = contextTargetIds() + let tabColorPalette = WorkspaceTabColorSettings.palette() let shouldPin = !tab.isPinned let pinLabel = targetIds.count > 1 ? (shouldPin ? "Pin Workspaces" : "Unpin Workspaces") @@ -2800,6 +2900,38 @@ private struct TabItemView: View { } } + Menu("Tab Color") { + if tab.customColor != nil { + Button { + applyTabColor(nil, targetIds: targetIds) + } label: { + Label("Clear Color", systemImage: "xmark.circle") + } + } + + Button { + promptCustomColor(targetIds: targetIds) + } label: { + Label("Choose Custom Color…", systemImage: "paintpalette") + } + + if !tabColorPalette.isEmpty { + Divider() + } + + ForEach(tabColorPalette, id: \.id) { entry in + Button { + applyTabColor(entry.hex, targetIds: targetIds) + } label: { + Label { + Text(entry.name) + } icon: { + Image(nsImage: coloredCircleImage(color: tabColorSwatchColor(for: entry.hex))) + } + } + } + } + Divider() Button("Move Up") { @@ -2863,13 +2995,50 @@ private struct TabItemView: View { } private var backgroundColor: Color { - if isActive { - return Color.accentColor + switch activeTabIndicatorStyle { + case .leftRail: + if isActive { return Color.accentColor } + if isMultiSelected { return Color.accentColor.opacity(0.25) } + return Color.clear + case .solidFill: + if let custom = resolvedCustomTabColor { + if isActive { return custom } + if isMultiSelected { return custom.opacity(0.35) } + return custom.opacity(0.7) + } + if isActive { return Color.accentColor } + if isMultiSelected { return Color.accentColor.opacity(0.25) } + return Color.clear } - if isMultiSelected { - return Color.accentColor.opacity(0.25) + } + + private var railColor: Color { + explicitRailColor ?? .clear + } + + private var explicitRailColor: Color? { + guard activeTabIndicatorStyle == .leftRail, + let custom = resolvedCustomTabColor else { + return nil } - return Color.clear + return custom.opacity(0.95) + } + + private var resolvedCustomTabColor: Color? { + guard let hex = tab.customColor else { return nil } + return WorkspaceTabColorSettings.displayColor( + hex: hex, + colorScheme: colorScheme, + forceBright: activeTabIndicatorStyle == .leftRail + ) + } + + private func tabColorSwatchColor(for hex: String) -> NSColor { + WorkspaceTabColorSettings.displayNSColor( + hex: hex, + colorScheme: colorScheme, + forceBright: activeTabIndicatorStyle == .leftRail + ) ?? NSColor(hex: hex) ?? .gray } private var showsCenteredTopDropIndicator: Bool { @@ -3142,6 +3311,55 @@ private struct TabItemView: View { return trimmed } + private func applyTabColor(_ hex: String?, targetIds: [UUID]) { + for targetId in targetIds { + tabManager.setTabColor(tabId: targetId, color: hex) + } + } + + private func promptCustomColor(targetIds: [UUID]) { + let alert = NSAlert() + alert.messageText = "Custom Tab Color" + alert.informativeText = "Enter a hex color in the format #RRGGBB." + + let seed = tab.customColor ?? WorkspaceTabColorSettings.customColors().first ?? "" + let input = NSTextField(string: seed) + input.placeholderString = "#1565C0" + input.frame = NSRect(x: 0, y: 0, width: 240, height: 22) + alert.accessoryView = input + alert.addButton(withTitle: "Apply") + alert.addButton(withTitle: "Cancel") + + let alertWindow = alert.window + alertWindow.initialFirstResponder = input + DispatchQueue.main.async { + alertWindow.makeFirstResponder(input) + input.selectText(nil) + } + + let response = alert.runModal() + guard response == .alertFirstButtonReturn else { return } + guard let normalized = WorkspaceTabColorSettings.addCustomColor(input.stringValue) else { + showInvalidColorAlert(input.stringValue) + return + } + applyTabColor(normalized, targetIds: targetIds) + } + + private func showInvalidColorAlert(_ value: String) { + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = "Invalid Color" + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + alert.informativeText = "Enter a hex color in the format #RRGGBB." + } else { + alert.informativeText = "\"\(trimmed)\" is not a valid hex color. Use #RRGGBB." + } + alert.addButton(withTitle: "OK") + _ = alert.runModal() + } + private func promptRename() { let alert = NSAlert() alert.messageText = "Rename Workspace" diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 0bcd5ea6..114a3f78 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -63,6 +63,48 @@ enum SidebarBranchLayoutSettings { } } +enum SidebarActiveTabIndicatorStyle: String, CaseIterable, Identifiable { + case leftRail + case solidFill + + var id: String { rawValue } + + var displayName: String { + switch self { + case .leftRail: + return "Left Rail" + case .solidFill: + return "Solid Fill" + } + } +} + +enum SidebarActiveTabIndicatorSettings { + static let styleKey = "sidebarActiveTabIndicatorStyle" + static let defaultStyle: SidebarActiveTabIndicatorStyle = .solidFill + + static func resolvedStyle(rawValue: String?) -> SidebarActiveTabIndicatorStyle { + guard let rawValue else { return defaultStyle } + if let style = SidebarActiveTabIndicatorStyle(rawValue: rawValue) { + return style + } + + // Legacy values from earlier iterations map to the closest modern option. + switch rawValue { + case "rail": + return .leftRail + case "border", "wash", "lift", "typography", "washRail", "blueWashColorRail": + return .solidFill + default: + return defaultStyle + } + } + + static func current(defaults: UserDefaults = .standard) -> SidebarActiveTabIndicatorStyle { + resolvedStyle(rawValue: defaults.string(forKey: styleKey)) + } +} + enum WorkspacePlacementSettings { static let placementKey = "newWorkspacePlacement" static let defaultPlacement: NewWorkspacePlacement = .afterCurrent @@ -104,6 +146,213 @@ enum WorkspacePlacementSettings { } } +struct WorkspaceTabColorEntry: Equatable, Identifiable { + let name: String + let hex: String + + var id: String { "\(name)-\(hex)" } +} + +enum WorkspaceTabColorSettings { + static let defaultOverridesKey = "workspaceTabColor.defaultOverrides" + static let customColorsKey = "workspaceTabColor.customColors" + static let maxCustomColors = 24 + + private static let originalPRPalette: [WorkspaceTabColorEntry] = [ + WorkspaceTabColorEntry(name: "Red", hex: "#C0392B"), + WorkspaceTabColorEntry(name: "Crimson", hex: "#922B21"), + WorkspaceTabColorEntry(name: "Orange", hex: "#A04000"), + WorkspaceTabColorEntry(name: "Amber", hex: "#7D6608"), + WorkspaceTabColorEntry(name: "Olive", hex: "#4A5C18"), + WorkspaceTabColorEntry(name: "Green", hex: "#196F3D"), + WorkspaceTabColorEntry(name: "Teal", hex: "#006B6B"), + WorkspaceTabColorEntry(name: "Aqua", hex: "#0E6B8C"), + WorkspaceTabColorEntry(name: "Blue", hex: "#1565C0"), + WorkspaceTabColorEntry(name: "Navy", hex: "#1A5276"), + WorkspaceTabColorEntry(name: "Indigo", hex: "#283593"), + WorkspaceTabColorEntry(name: "Purple", hex: "#6A1B9A"), + WorkspaceTabColorEntry(name: "Magenta", hex: "#AD1457"), + WorkspaceTabColorEntry(name: "Rose", hex: "#880E4F"), + WorkspaceTabColorEntry(name: "Brown", hex: "#7B3F00"), + WorkspaceTabColorEntry(name: "Charcoal", hex: "#3E4B5E"), + ] + + static var defaultPalette: [WorkspaceTabColorEntry] { + originalPRPalette + } + + static func palette(defaults: UserDefaults = .standard) -> [WorkspaceTabColorEntry] { + defaultPaletteWithOverrides(defaults: defaults) + customColorEntries(defaults: defaults) + } + + static func defaultPaletteWithOverrides(defaults: UserDefaults = .standard) -> [WorkspaceTabColorEntry] { + let palette = defaultPalette + let overrides = defaultOverrideMap(defaults: defaults) + return palette.map { entry in + WorkspaceTabColorEntry(name: entry.name, hex: overrides[entry.name] ?? entry.hex) + } + } + + static func defaultColorHex(named name: String, defaults: UserDefaults = .standard) -> String { + let palette = defaultPalette + guard let entry = palette.first(where: { $0.name == name }) else { + return palette.first?.hex ?? "#1565C0" + } + return defaultOverrideMap(defaults: defaults)[name] ?? entry.hex + } + + static func setDefaultColor(named name: String, hex: String, defaults: UserDefaults = .standard) { + let palette = defaultPalette + guard let entry = palette.first(where: { $0.name == name }), + let normalized = normalizedHex(hex) else { return } + + var overrides = defaultOverrideMap(defaults: defaults) + if normalized == entry.hex { + overrides.removeValue(forKey: name) + } else { + overrides[name] = normalized + } + saveDefaultOverrideMap(overrides, defaults: defaults) + } + + static func customColors(defaults: UserDefaults = .standard) -> [String] { + guard let raw = defaults.array(forKey: customColorsKey) as? [String] else { return [] } + var result: [String] = [] + var seen: Set = [] + for value in raw { + guard let normalized = normalizedHex(value), seen.insert(normalized).inserted else { continue } + result.append(normalized) + if result.count >= maxCustomColors { break } + } + return result + } + + static func customColorEntries(defaults: UserDefaults = .standard) -> [WorkspaceTabColorEntry] { + customColors(defaults: defaults).enumerated().map { index, hex in + WorkspaceTabColorEntry(name: "Custom \(index + 1)", hex: hex) + } + } + + @discardableResult + static func addCustomColor(_ hex: String, defaults: UserDefaults = .standard) -> String? { + guard let normalized = normalizedHex(hex) else { return nil } + var colors = customColors(defaults: defaults) + colors.removeAll { $0 == normalized } + colors.insert(normalized, at: 0) + setCustomColors(colors, defaults: defaults) + return normalized + } + + static func removeCustomColor(_ hex: String, defaults: UserDefaults = .standard) { + guard let normalized = normalizedHex(hex) else { return } + var colors = customColors(defaults: defaults) + colors.removeAll { $0 == normalized } + setCustomColors(colors, defaults: defaults) + } + + static func setCustomColors(_ hexes: [String], defaults: UserDefaults = .standard) { + var normalizedColors: [String] = [] + var seen: Set = [] + for value in hexes { + guard let normalized = normalizedHex(value), seen.insert(normalized).inserted else { continue } + normalizedColors.append(normalized) + if normalizedColors.count >= maxCustomColors { break } + } + + if normalizedColors.isEmpty { + defaults.removeObject(forKey: customColorsKey) + } else { + defaults.set(normalizedColors, forKey: customColorsKey) + } + } + + static func reset(defaults: UserDefaults = .standard) { + defaults.removeObject(forKey: defaultOverridesKey) + defaults.removeObject(forKey: customColorsKey) + } + + static func normalizedHex(_ raw: String) -> String? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + let body = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed + guard body.count == 6 else { return nil } + guard UInt64(body, radix: 16) != nil else { return nil } + return "#" + body.uppercased() + } + + static func displayColor( + hex: String, + colorScheme: ColorScheme, + forceBright: Bool = false + ) -> Color? { + guard let color = displayNSColor(hex: hex, colorScheme: colorScheme, forceBright: forceBright) else { + return nil + } + return Color(nsColor: color) + } + + static func displayNSColor( + hex: String, + colorScheme: ColorScheme, + forceBright: Bool = false + ) -> NSColor? { + guard let normalized = normalizedHex(hex), + let baseColor = NSColor(hex: normalized) else { + return nil + } + + if forceBright || colorScheme == .dark { + return brightenedForDarkAppearance(baseColor) + } + return baseColor + } + + private static func defaultOverrideMap(defaults: UserDefaults) -> [String: String] { + guard let raw = defaults.dictionary(forKey: defaultOverridesKey) as? [String: String] else { return [:] } + let validNames = Set(defaultPalette.map(\.name)) + var normalized: [String: String] = [:] + for (name, hex) in raw { + guard validNames.contains(name), + let normalizedHex = normalizedHex(hex) else { continue } + normalized[name] = normalizedHex + } + return normalized + } + + private static func saveDefaultOverrideMap(_ map: [String: String], defaults: UserDefaults) { + if map.isEmpty { + defaults.removeObject(forKey: defaultOverridesKey) + } else { + defaults.set(map, forKey: defaultOverridesKey) + } + } + + private static func brightenedForDarkAppearance(_ color: NSColor) -> NSColor { + let rgbColor = color.usingColorSpace(.sRGB) ?? color + var hue: CGFloat = 0 + var saturation: CGFloat = 0 + var brightness: CGFloat = 0 + var alpha: CGFloat = 0 + rgbColor.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha) + + let boostedBrightness = min(1, max(brightness, 0.62) + ((1 - brightness) * 0.28)) + // Preserve neutral grays when brightening to avoid introducing hue shifts. + let boostedSaturation: CGFloat + if saturation <= 0.08 { + boostedSaturation = saturation + } else { + boostedSaturation = min(1, saturation + ((1 - saturation) * 0.12)) + } + + return NSColor( + hue: hue, + saturation: boostedSaturation, + brightness: boostedBrightness, + alpha: alpha + ) + } +} + /// Coalesces repeated main-thread signals into one callback after a short delay. /// Useful for notification storms where only the latest update matters. final class NotificationBurstCoalescer { @@ -632,6 +881,11 @@ class TabManager: ObservableObject { setCustomTitle(tabId: tabId, title: nil) } + func setTabColor(tabId: UUID, color: String?) { + guard let tab = tabs.first(where: { $0.id == tabId }) else { return } + tab.setCustomColor(color) + } + func togglePin(tabId: UUID) { guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return } let tab = tabs[index] diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 547cd84b..bb0345d4 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -243,6 +243,7 @@ final class Workspace: Identifiable, ObservableObject { @Published var title: String @Published var customTitle: String? @Published var isPinned: Bool = false + @Published var customColor: String? // hex string, e.g. "#C0392B" @Published var currentDirectory: String /// Ordinal for CMUX_PORT range assignment (monotonically increasing per app session) @@ -755,6 +756,10 @@ final class Workspace: Identifiable, ObservableObject { self.title = title } + func setCustomColor(_ hex: String?) { + customColor = hex + } + func setCustomTitle(_ title: String?) { let trimmed = title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if trimmed.isEmpty { diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 60190705..024d1ce8 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -298,6 +298,10 @@ struct cmuxApp: App { appDelegate.openDebugScrollbackTab(nil) } + Button("Open Workspaces for All Tab Colors") { + appDelegate.openDebugColorComparisonWorkspaces(nil) + } + Divider() Menu("Debug Windows") { Button("Debug Window Controls…") { @@ -1223,6 +1227,7 @@ private enum DebugWindowConfigSnapshot { sidebarTintOpacity=\(String(format: "%.2f", doubleValue(defaults, key: "sidebarTintOpacity", fallback: 0.18))) sidebarCornerRadius=\(String(format: "%.1f", doubleValue(defaults, key: "sidebarCornerRadius", fallback: 0.0))) sidebarBranchVerticalLayout=\(boolValue(defaults, key: SidebarBranchLayoutSettings.key, fallback: SidebarBranchLayoutSettings.defaultVerticalLayout)) + sidebarActiveTabIndicatorStyle=\(stringValue(defaults, key: SidebarActiveTabIndicatorSettings.styleKey, fallback: SidebarActiveTabIndicatorSettings.defaultStyle.rawValue)) shortcutHintSidebarXOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.sidebarHintXKey, fallback: ShortcutHintDebugSettings.defaultSidebarHintX))) shortcutHintSidebarYOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.sidebarHintYKey, fallback: ShortcutHintDebugSettings.defaultSidebarHintY))) shortcutHintTitlebarXOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.titlebarHintXKey, fallback: ShortcutHintDebugSettings.defaultTitlebarHintX))) @@ -1319,6 +1324,8 @@ private struct DebugWindowControlsView: View { @AppStorage(ShortcutHintDebugSettings.paneHintXKey) private var paneShortcutHintXOffset = ShortcutHintDebugSettings.defaultPaneHintX @AppStorage(ShortcutHintDebugSettings.paneHintYKey) private var paneShortcutHintYOffset = ShortcutHintDebugSettings.defaultPaneHintY @AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints + @AppStorage(SidebarActiveTabIndicatorSettings.styleKey) + private var sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue @AppStorage("debugTitlebarLeadingExtra") private var titlebarLeadingExtra: Double = 0 @AppStorage(BrowserDevToolsButtonDebugSettings.iconNameKey) private var browserDevToolsIconNameRaw = BrowserDevToolsButtonDebugSettings.defaultIcon.rawValue @AppStorage(BrowserDevToolsButtonDebugSettings.iconColorKey) private var browserDevToolsIconColorRaw = BrowserDevToolsButtonDebugSettings.defaultColor.rawValue @@ -1331,6 +1338,17 @@ private struct DebugWindowControlsView: View { BrowserDevToolsIconColorOption(rawValue: browserDevToolsIconColorRaw) ?? BrowserDevToolsButtonDebugSettings.defaultColor } + private var selectedSidebarActiveTabIndicatorStyle: SidebarActiveTabIndicatorStyle { + SidebarActiveTabIndicatorSettings.resolvedStyle(rawValue: sidebarActiveTabIndicatorStyle) + } + + private var sidebarIndicatorStyleSelection: Binding { + Binding( + get: { selectedSidebarActiveTabIndicatorStyle.rawValue }, + set: { sidebarActiveTabIndicatorStyle = $0 } + ) + } + var body: some View { ScrollView { VStack(alignment: .leading, spacing: 14) { @@ -1396,6 +1414,22 @@ private struct DebugWindowControlsView: View { .padding(.top, 2) } + GroupBox("Active Workspace Indicator") { + VStack(alignment: .leading, spacing: 8) { + Picker("Style", selection: sidebarIndicatorStyleSelection) { + ForEach(SidebarActiveTabIndicatorStyle.allCases) { style in + Text(style.displayName).tag(style.rawValue) + } + } + .pickerStyle(.menu) + + Button("Reset Indicator Style") { + sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue + } + } + .padding(.top, 2) + } + GroupBox("Titlebar Spacing") { VStack(alignment: .leading, spacing: 6) { HStack(spacing: 8) { @@ -1797,6 +1831,19 @@ private struct SidebarDebugView: View { @AppStorage(ShortcutHintDebugSettings.paneHintXKey) private var paneShortcutHintXOffset = ShortcutHintDebugSettings.defaultPaneHintX @AppStorage(ShortcutHintDebugSettings.paneHintYKey) private var paneShortcutHintYOffset = ShortcutHintDebugSettings.defaultPaneHintY @AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints + @AppStorage(SidebarActiveTabIndicatorSettings.styleKey) + private var sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue + + private var selectedSidebarIndicatorStyle: SidebarActiveTabIndicatorStyle { + SidebarActiveTabIndicatorSettings.resolvedStyle(rawValue: sidebarActiveTabIndicatorStyle) + } + + private var sidebarIndicatorStyleSelection: Binding { + Binding( + get: { selectedSidebarIndicatorStyle.rawValue }, + set: { sidebarActiveTabIndicatorStyle = $0 } + ) + } var body: some View { ScrollView { @@ -1898,6 +1945,17 @@ private struct SidebarDebugView: View { .padding(.top, 2) } + GroupBox("Active Workspace Indicator") { + VStack(alignment: .leading, spacing: 8) { + Picker("Style", selection: sidebarIndicatorStyleSelection) { + ForEach(SidebarActiveTabIndicatorStyle.allCases) { style in + Text(style.displayName).tag(style.rawValue) + } + } + } + .padding(.top, 2) + } + GroupBox("Workspace Metadata") { VStack(alignment: .leading, spacing: 8) { Toggle("Render branch list vertically", isOn: $sidebarBranchVerticalLayout) @@ -1925,6 +1983,9 @@ private struct SidebarDebugView: View { Button("Reset Hints") { resetShortcutHintOffsets() } + Button("Reset Active Indicator") { + sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue + } } Button("Copy Config") { @@ -1992,6 +2053,7 @@ private struct SidebarDebugView: View { sidebarTintOpacity=\(String(format: "%.2f", sidebarTintOpacity)) sidebarCornerRadius=\(String(format: "%.1f", sidebarCornerRadius)) sidebarBranchVerticalLayout=\(sidebarBranchVerticalLayout) + sidebarActiveTabIndicatorStyle=\(sidebarActiveTabIndicatorStyle) shortcutHintSidebarXOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(sidebarShortcutHintXOffset))) shortcutHintSidebarYOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(sidebarShortcutHintYOffset))) shortcutHintTitlebarXOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(titlebarShortcutHintXOffset))) @@ -2505,6 +2567,8 @@ struct SettingsView: View { @AppStorage(WorkspacePlacementSettings.placementKey) private var newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue @AppStorage(WorkspaceAutoReorderSettings.key) private var workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue @AppStorage(SidebarBranchLayoutSettings.key) private var sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout + @AppStorage(SidebarActiveTabIndicatorSettings.styleKey) + private var sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue @State private var shortcutResetToken = UUID() @State private var topBlurOpacity: Double = 0 @State private var topBlurBaselineOffset: CGFloat? @@ -2517,11 +2581,24 @@ struct SettingsView: View { @State private var socketPasswordDraft = "" @State private var socketPasswordStatusMessage: String? @State private var socketPasswordStatusIsError = false + @State private var workspaceTabDefaultEntries = WorkspaceTabColorSettings.defaultPaletteWithOverrides() + @State private var workspaceTabCustomColors = WorkspaceTabColorSettings.customColors() private var selectedWorkspacePlacement: NewWorkspacePlacement { NewWorkspacePlacement(rawValue: newWorkspacePlacement) ?? WorkspacePlacementSettings.defaultPlacement } + private var selectedSidebarActiveTabIndicatorStyle: SidebarActiveTabIndicatorStyle { + SidebarActiveTabIndicatorSettings.resolvedStyle(rawValue: sidebarActiveTabIndicatorStyle) + } + + private var sidebarIndicatorStyleSelection: Binding { + Binding( + get: { selectedSidebarActiveTabIndicatorStyle.rawValue }, + set: { sidebarActiveTabIndicatorStyle = $0 } + ) + } + private var selectedSocketControlMode: SocketControlMode { SocketControlSettings.migrateMode(socketControlMode) } @@ -2683,6 +2760,97 @@ struct SettingsView: View { .labelsHidden() .pickerStyle(.menu) } + + SettingsCardDivider() + + SettingsCardRow( + "Active Workspace Indicator", + controlWidth: pickerColumnWidth + ) { + Picker("", selection: sidebarIndicatorStyleSelection) { + ForEach(SidebarActiveTabIndicatorStyle.allCases) { style in + Text(style.displayName).tag(style.rawValue) + } + } + .labelsHidden() + .pickerStyle(.menu) + } + } + + SettingsSectionHeader(title: "Workspace Colors") + SettingsCard { + SettingsCardNote("Customize the workspace color palette used by Sidebar > Tab Color. \"Choose Custom Color...\" entries are persisted below.") + + ForEach(Array(workspaceTabDefaultEntries.enumerated()), id: \.element.name) { index, entry in + if index > 0 { + SettingsCardDivider() + } + SettingsCardRow( + entry.name, + subtitle: "Base: \(baseTabColorHex(for: entry.name))" + ) { + HStack(spacing: 8) { + ColorPicker( + "", + selection: defaultTabColorBinding(for: entry.name), + supportsOpacity: false + ) + .labelsHidden() + .frame(width: 38) + + Text(entry.hex) + .font(.system(size: 12, weight: .medium, design: .monospaced)) + .foregroundStyle(.secondary) + .frame(width: 76, alignment: .trailing) + } + } + } + + SettingsCardDivider() + + if workspaceTabCustomColors.isEmpty { + SettingsCardNote("Custom colors: none yet. Use \"Choose Custom Color...\" from a workspace context menu.") + } else { + VStack(alignment: .leading, spacing: 8) { + Text("Custom Colors") + .font(.system(size: 13, weight: .semibold)) + + ForEach(workspaceTabCustomColors, id: \.self) { hex in + HStack(spacing: 8) { + Circle() + .fill(Color(nsColor: NSColor(hex: hex) ?? .gray)) + .frame(width: 11, height: 11) + + Text(hex) + .font(.system(size: 12, weight: .medium, design: .monospaced)) + .foregroundStyle(.secondary) + + Spacer(minLength: 8) + + Button("Remove") { + removeWorkspaceCustomColor(hex) + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + } + + SettingsCardDivider() + + SettingsCardRow( + "Reset Palette", + subtitle: "Restore built-in defaults and clear all custom colors." + ) { + Button("Reset") { + resetWorkspaceTabColors() + } + .buttonStyle(.bordered) + .controlSize(.small) + } } SettingsSectionHeader(title: "Automation") @@ -3081,6 +3249,7 @@ struct SettingsView: View { browserForcedDarkModeOpacity = BrowserForcedDarkModeSettings.normalizedOpacity(browserForcedDarkModeOpacity) browserHistoryEntryCount = BrowserHistoryStore.shared.entries.count browserInsecureHTTPAllowlistDraft = browserInsecureHTTPAllowlist + reloadWorkspaceTabColorSettings() } .onChange(of: browserInsecureHTTPAllowlist) { oldValue, newValue in // Keep draft in sync with external changes unless the user has local unsaved edits. @@ -3091,6 +3260,9 @@ struct SettingsView: View { .onReceive(BrowserHistoryStore.shared.$entries) { entries in browserHistoryEntryCount = entries.count } + .onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in + reloadWorkspaceTabColorSettings() + } .confirmationDialog( "Clear browser history?", isPresented: $showClearBrowserHistoryConfirmation, @@ -3137,15 +3309,53 @@ struct SettingsView: View { newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout + sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue showOpenAccessConfirmation = false pendingOpenAccessMode = nil socketPasswordDraft = "" socketPasswordStatusMessage = nil socketPasswordStatusIsError = false KeyboardShortcutSettings.resetAll() + WorkspaceTabColorSettings.reset() + reloadWorkspaceTabColorSettings() shortcutResetToken = UUID() } + private func defaultTabColorBinding(for name: String) -> Binding { + Binding( + get: { + let hex = WorkspaceTabColorSettings.defaultColorHex(named: name) + return Color(nsColor: NSColor(hex: hex) ?? .systemBlue) + }, + set: { newValue in + let hex = NSColor(newValue).hexString() + WorkspaceTabColorSettings.setDefaultColor(named: name, hex: hex) + reloadWorkspaceTabColorSettings() + } + ) + } + + private func baseTabColorHex(for name: String) -> String { + WorkspaceTabColorSettings.defaultPalette + .first(where: { $0.name == name })? + .hex ?? "#1565C0" + } + + private func removeWorkspaceCustomColor(_ hex: String) { + WorkspaceTabColorSettings.removeCustomColor(hex) + reloadWorkspaceTabColorSettings() + } + + private func resetWorkspaceTabColors() { + WorkspaceTabColorSettings.reset() + reloadWorkspaceTabColorSettings() + } + + private func reloadWorkspaceTabColorSettings() { + workspaceTabDefaultEntries = WorkspaceTabColorSettings.defaultPaletteWithOverrides() + workspaceTabCustomColors = WorkspaceTabColorSettings.customColors() + } + private func saveBrowserInsecureHTTPAllowlist() { browserInsecureHTTPAllowlist = browserInsecureHTTPAllowlistDraft } diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 0cd08c71..01777355 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -1,5 +1,6 @@ import XCTest import AppKit +import SwiftUI import WebKit import SwiftUI import ObjectiveC.runtime @@ -1066,6 +1067,171 @@ final class WorkspacePlacementSettingsTests: XCTestCase { } } +final class WorkspaceTabColorSettingsTests: XCTestCase { + func testNormalizedHexAcceptsAndNormalizesValidInput() { + XCTAssertEqual(WorkspaceTabColorSettings.normalizedHex("#abc123"), "#ABC123") + XCTAssertEqual(WorkspaceTabColorSettings.normalizedHex(" aBcDeF "), "#ABCDEF") + XCTAssertNil(WorkspaceTabColorSettings.normalizedHex("#1234")) + XCTAssertNil(WorkspaceTabColorSettings.normalizedHex("#GG1234")) + } + + func testBuiltInPaletteMatchesOriginalPRPalette() { + let suiteName = "WorkspaceTabColorSettingsTests.BuiltInPalette.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + let palette = WorkspaceTabColorSettings.defaultPaletteWithOverrides(defaults: defaults) + XCTAssertEqual(palette.count, 16) + XCTAssertEqual(palette.first?.name, "Red") + XCTAssertEqual(palette.first?.hex, "#C0392B") + XCTAssertEqual(palette.last?.name, "Charcoal") + XCTAssertFalse(palette.contains(where: { $0.name == "Gold" })) + } + + func testDefaultOverrideRoundTripFallsBackWhenResetToBase() { + let suiteName = "WorkspaceTabColorSettingsTests.DefaultOverride.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + let first = WorkspaceTabColorSettings.defaultPalette[0] + XCTAssertEqual( + WorkspaceTabColorSettings.defaultColorHex(named: first.name, defaults: defaults), + first.hex + ) + + WorkspaceTabColorSettings.setDefaultColor(named: first.name, hex: "#00aa33", defaults: defaults) + XCTAssertEqual( + WorkspaceTabColorSettings.defaultColorHex(named: first.name, defaults: defaults), + "#00AA33" + ) + + WorkspaceTabColorSettings.setDefaultColor(named: first.name, hex: first.hex, defaults: defaults) + XCTAssertEqual( + WorkspaceTabColorSettings.defaultColorHex(named: first.name, defaults: defaults), + first.hex + ) + } + + func testAddCustomColorPersistsAndDeduplicatesByMostRecent() { + let suiteName = "WorkspaceTabColorSettingsTests.CustomColors.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + XCTAssertEqual( + WorkspaceTabColorSettings.addCustomColor(" #00aa33 ", defaults: defaults), + "#00AA33" + ) + XCTAssertEqual( + WorkspaceTabColorSettings.addCustomColor("#112233", defaults: defaults), + "#112233" + ) + XCTAssertEqual( + WorkspaceTabColorSettings.addCustomColor("#00AA33", defaults: defaults), + "#00AA33" + ) + XCTAssertNil(WorkspaceTabColorSettings.addCustomColor("nope", defaults: defaults)) + + XCTAssertEqual( + WorkspaceTabColorSettings.customColors(defaults: defaults), + ["#00AA33", "#112233"] + ) + } + + func testPaletteIncludesCustomEntriesAndResetClearsAll() { + let suiteName = "WorkspaceTabColorSettingsTests.Reset.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + let first = WorkspaceTabColorSettings.defaultPalette[0] + WorkspaceTabColorSettings.setDefaultColor(named: first.name, hex: "#334455", defaults: defaults) + _ = WorkspaceTabColorSettings.addCustomColor("#778899", defaults: defaults) + + let paletteBeforeReset = WorkspaceTabColorSettings.palette(defaults: defaults) + XCTAssertEqual(paletteBeforeReset.count, WorkspaceTabColorSettings.defaultPalette.count + 1) + XCTAssertEqual(paletteBeforeReset[0].hex, "#334455") + XCTAssertEqual(paletteBeforeReset.last?.name, "Custom 1") + XCTAssertEqual(paletteBeforeReset.last?.hex, "#778899") + + WorkspaceTabColorSettings.reset(defaults: defaults) + + XCTAssertEqual(WorkspaceTabColorSettings.customColors(defaults: defaults), []) + XCTAssertEqual( + WorkspaceTabColorSettings.defaultColorHex(named: first.name, defaults: defaults), + first.hex + ) + } + + func testDisplayColorLightModeKeepsOriginalHex() { + let originalHex = "#1A5276" + let rendered = WorkspaceTabColorSettings.displayNSColor( + hex: originalHex, + colorScheme: .light + ) + + XCTAssertEqual(rendered?.hexString(), originalHex) + } + + func testDisplayColorDarkModeBrightensColor() { + let originalHex = "#1A5276" + guard let base = NSColor(hex: originalHex), + let rendered = WorkspaceTabColorSettings.displayNSColor( + hex: originalHex, + colorScheme: .dark + ) else { + XCTFail("Expected valid color conversion") + return + } + + XCTAssertNotEqual(rendered.hexString(), originalHex) + XCTAssertGreaterThan(rendered.luminance, base.luminance) + } + + func testDisplayColorDarkModeKeepsGrayscaleNeutral() { + let originalHex = "#808080" + guard let base = NSColor(hex: originalHex), + let rendered = WorkspaceTabColorSettings.displayNSColor( + hex: originalHex, + colorScheme: .dark + ), + let renderedSRGB = rendered.usingColorSpace(.sRGB) else { + XCTFail("Expected valid color conversion") + return + } + + XCTAssertGreaterThan(rendered.luminance, base.luminance) + XCTAssertLessThan(abs(renderedSRGB.redComponent - renderedSRGB.greenComponent), 0.003) + XCTAssertLessThan(abs(renderedSRGB.greenComponent - renderedSRGB.blueComponent), 0.003) + } + + func testDisplayColorForceBrightensInLightMode() { + let originalHex = "#1A5276" + guard let base = NSColor(hex: originalHex), + let rendered = WorkspaceTabColorSettings.displayNSColor( + hex: originalHex, + colorScheme: .light, + forceBright: true + ) else { + XCTFail("Expected valid color conversion") + return + } + + XCTAssertNotEqual(rendered.hexString(), originalHex) + XCTAssertGreaterThan(rendered.luminance, base.luminance) + } +} + final class WorkspaceAutoReorderSettingsTests: XCTestCase { func testDefaultIsEnabled() { let suiteName = "WorkspaceAutoReorderSettingsTests.Default.\(UUID().uuidString)" @@ -1131,6 +1297,44 @@ final class SidebarBranchLayoutSettingsTests: XCTestCase { } } +final class SidebarActiveTabIndicatorSettingsTests: XCTestCase { + func testDefaultStyleWhenUnset() { + let suiteName = "SidebarActiveTabIndicatorSettingsTests.Default.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.removeObject(forKey: SidebarActiveTabIndicatorSettings.styleKey) + XCTAssertEqual( + SidebarActiveTabIndicatorSettings.current(defaults: defaults), + SidebarActiveTabIndicatorSettings.defaultStyle + ) + } + + func testStoredStyleParsesAndInvalidFallsBack() { + let suiteName = "SidebarActiveTabIndicatorSettingsTests.Stored.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.set(SidebarActiveTabIndicatorStyle.leftRail.rawValue, forKey: SidebarActiveTabIndicatorSettings.styleKey) + XCTAssertEqual(SidebarActiveTabIndicatorSettings.current(defaults: defaults), .leftRail) + + defaults.set("rail", forKey: SidebarActiveTabIndicatorSettings.styleKey) + XCTAssertEqual(SidebarActiveTabIndicatorSettings.current(defaults: defaults), .leftRail) + + defaults.set("not-a-style", forKey: SidebarActiveTabIndicatorSettings.styleKey) + XCTAssertEqual( + SidebarActiveTabIndicatorSettings.current(defaults: defaults), + SidebarActiveTabIndicatorSettings.defaultStyle + ) + } +} + final class AppearanceSettingsTests: XCTestCase { func testResolvedModeDefaultsToSystemWhenUnset() { let suiteName = "AppearanceSettingsTests.Default.\(UUID().uuidString)" diff --git a/cmuxUITests/UpdatePillUITests.swift b/cmuxUITests/UpdatePillUITests.swift index 88c00b53..b8abb185 100644 --- a/cmuxUITests/UpdatePillUITests.swift +++ b/cmuxUITests/UpdatePillUITests.swift @@ -1,6 +1,24 @@ import XCTest import Foundation +// UI runners can adjust wall clock time mid-test; use monotonic uptime for polling deadlines. +private func pollUntil( + timeout: TimeInterval, + pollInterval: TimeInterval = 0.05, + condition: () -> Bool +) -> Bool { + let start = ProcessInfo.processInfo.systemUptime + while true { + if condition() { + return true + } + if (ProcessInfo.processInfo.systemUptime - start) >= timeout { + return false + } + RunLoop.current.run(until: Date().addingTimeInterval(pollInterval)) + } +} + final class UpdatePillUITests: XCTestCase { override func setUp() { super.setUp() @@ -131,25 +149,28 @@ final class UpdatePillUITests: XCTestCase { } private func waitForWindowCount(atLeast count: Int, app: XCUIApplication, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if app.windows.count >= count { return true } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + pollUntil(timeout: timeout) { + app.windows.count >= count } - return app.windows.count >= count } private func assertVisibleSize(_ element: XCUIElement, timeout: TimeInterval = 2.0) { - let deadline = Date().addingTimeInterval(timeout) + let pollInterval: TimeInterval = 0.05 var size = element.frame.size - while Date() < deadline { + var exists = element.exists + var hittable = element.isHittable + + let visible = pollUntil(timeout: timeout, pollInterval: pollInterval) { size = element.frame.size - if size.width > 20 && size.height > 10 { - return - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + exists = element.exists + hittable = element.isHittable + return size.width > 20 && size.height > 10 + } + if !visible { + XCTFail( + "Expected UpdatePill to have visible size, got \(size), exists=\(exists), hittable=\(hittable)" + ) } - XCTFail("Expected UpdatePill to have visible size, got \(size)") } private func attachScreenshot(name: String, screenshot: XCUIScreenshot = XCUIScreen.main.screenshot()) { @@ -197,12 +218,14 @@ final class UpdatePillUITests: XCTestCase { private func launchAndActivate(_ app: XCUIApplication, activateTimeout: TimeInterval = 2.0) { app.launch() - let deadline = Date().addingTimeInterval(activateTimeout) - while Date() < deadline, app.state != .runningForeground { + let activated = pollUntil(timeout: activateTimeout) { + guard app.state != .runningForeground else { + return true + } app.activate() - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + return app.state == .runningForeground } - if app.state != .runningForeground { + if !activated { app.activate() } } @@ -293,40 +316,32 @@ final class TitlebarShortcutHintsUITests: XCTestCase { app.launchArguments += ["-shortcutHintTitlebarYOffset", "0"] app.launch() - let deadline = Date().addingTimeInterval(2.0) - while Date() < deadline, app.state != .runningForeground { + _ = pollUntil(timeout: 2.0) { + guard app.state != .runningForeground else { + return true + } app.activate() - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + return app.state == .runningForeground } return app } private func waitForWindowCount(atLeast count: Int, app: XCUIApplication, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if app.windows.count >= count { return true } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + pollUntil(timeout: timeout) { + app.windows.count >= count } - return app.windows.count >= count } private func waitForElementVisible(_ element: XCUIElement, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { + pollUntil(timeout: timeout) { if element.exists { let frame = element.frame if frame.width > 1, frame.height > 1 { return true } } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + return false } - - if element.exists { - let frame = element.frame - return frame.width > 1 && frame.height > 1 - } - return false } } diff --git a/skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh b/skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh index ac08502d..7798e8e7 100755 --- a/skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh +++ b/skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh @@ -93,6 +93,7 @@ sidebarBlurOpacity="$(format_number "$(read_value sidebarBlurOpacity 0.79)" 2)" sidebarTintHex="$(read_value sidebarTintHex '#101010')" sidebarTintOpacity="$(format_number "$(read_value sidebarTintOpacity 0.54)" 2)" sidebarCornerRadius="$(format_number "$(read_value sidebarCornerRadius 0.0)" 1)" +sidebarActiveTabIndicatorStyle="$(read_value sidebarActiveTabIndicatorStyle solidFill)" shortcutHintSidebarXOffset="$(format_number "$(read_value shortcutHintSidebarXOffset 0.0)" 1)" shortcutHintSidebarYOffset="$(format_number "$(read_value shortcutHintSidebarYOffset 0.0)" 1)" shortcutHintTitlebarXOffset="$(format_number "$(read_value shortcutHintTitlebarXOffset 4.0)" 1)" @@ -141,6 +142,7 @@ sidebarBlurOpacity=$sidebarBlurOpacity sidebarTintHex=$sidebarTintHex sidebarTintOpacity=$sidebarTintOpacity sidebarCornerRadius=$sidebarCornerRadius +sidebarActiveTabIndicatorStyle=$sidebarActiveTabIndicatorStyle shortcutHintSidebarXOffset=$shortcutHintSidebarXOffset shortcutHintSidebarYOffset=$shortcutHintSidebarYOffset shortcutHintTitlebarXOffset=$shortcutHintTitlebarXOffset